From fb69e42814c5ed0a473574eb0fb078fdac92cb7f Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 22 Nov 2024 15:29:02 -0600 Subject: [PATCH 01/78] build with JDK 21 as the minimum --- README.md | 2 +- build.gradle | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 80e6a42..7087193 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ The counts can be reset using `/__stats?reset`. The `requests/sec` is calculated io.github.robaho httpserver - 1.0.9 + 1.0.10 ``` ## future work diff --git a/build.gradle b/build.gradle index 8e90fe7..df7c67b 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ repositories { java { toolchain { - languageVersion = JavaLanguageVersion.of(23) + languageVersion = JavaLanguageVersion.of(21) } withSourcesJar() withJavadocJar() @@ -149,7 +149,7 @@ publishing { maven(MavenPublication) { groupId = 'io.github.robaho' artifactId = 'httpserver' - version = "1.0.9" + version = "1.0.10" from components.java From ab47ee4d941a5716fb35fb195979af83261021c1 Mon Sep 17 00:00:00 2001 From: "robin.bygrave" Date: Tue, 10 Dec 2024 13:41:14 +1300 Subject: [PATCH 02/78] Change WebSocket to use System.Logger rather than JUL Noting that System.Logger can be redirected via service loading, I didn't expect to see the mix of both System.Logger and JUL. This change shows how WebSocket could change to use System.Logger. --- .../net/httpserver/websockets/WebSocket.java | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/main/java/robaho/net/httpserver/websockets/WebSocket.java b/src/main/java/robaho/net/httpserver/websockets/WebSocket.java index 7317c4b..ae9d9da 100644 --- a/src/main/java/robaho/net/httpserver/websockets/WebSocket.java +++ b/src/main/java/robaho/net/httpserver/websockets/WebSocket.java @@ -43,14 +43,14 @@ import java.util.List; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; -import java.util.logging.Level; -import java.util.logging.Logger; import com.sun.net.httpserver.HttpExchange; +import static java.lang.System.Logger.Level.*; + public abstract class WebSocket { - Logger logger = Logger.getLogger(WebSocket.class.getName()); + protected final System.Logger logger = System.getLogger("robaho.net.httpserver.websockets"); private final InputStream in; private final OutputStream out; @@ -68,7 +68,7 @@ public abstract class WebSocket { protected WebSocket(HttpExchange exchange) { this.uri = exchange.getRequestURI(); - logger.info("connecting websocket "+uri); + logger.log(INFO, "connecting websocket {0}", uri); this.state = State.CONNECTING; this.in = exchange.getRequestBody(); @@ -90,12 +90,12 @@ protected void onOpen() throws WebSocketException { protected void onException(IOException exception) { if(state!=State.CLOSING && state!=State.CLOSED) { - logger.log(Level.FINER, "exception on websocket", exception); + logger.log(TRACE, "exception on websocket", exception); } } protected void onFrameReceived(WebSocketFrame frame) { - logger.log(Level.FINER, () -> "frame received: " + frame); + logger.log(TRACE, "frame received: {0}", frame); } /** @@ -106,11 +106,11 @@ protected void onFrameReceived(WebSocketFrame frame) { * The sent WebSocket Frame. */ protected void onFrameSent(WebSocketFrame frame) { - logger.log(Level.FINER, () -> "frame sent: " + frame); + logger.log(TRACE, "frame sent: {0}", frame); } public void close(CloseCode code, String reason, boolean initiatedByRemote) throws IOException { - logger.info("closing websocket "+uri); + logger.log(INFO, "closing websocket {0}", uri); State oldState = this.state; this.state = State.CLOSING; @@ -150,7 +150,7 @@ private void handleCloseFrame(WebSocketFrame frame) throws IOException { code = ((CloseFrame) frame).getCloseCode(); reason = ((CloseFrame) frame).getCloseReason(); } - logger.finest("handleCloseFrame: "+uri+", code="+code+", reason="+reason+", state "+this.state); + logger.log(TRACE, "handleCloseFrame: {0}, code={1}, reason={2}, state {3}", uri, code, reason, this.state); if (this.state == State.CLOSING) { // Answer for my requested close doClose(code, reason, false); @@ -215,7 +215,7 @@ public void ping(byte[] payload) throws IOException { void readWebsocket() { try { state = State.OPEN; - logger.fine("websocket open "+uri); + logger.log(DEBUG, "websocket open {0}", uri); onOpen(); while (this.state == State.OPEN) { handleWebsocketFrame(WebSocketFrame.read(in)); @@ -235,7 +235,7 @@ void readWebsocket() { } } finally { doClose(CloseCode.InternalServerError, "Handler terminated without closing the connection.", false); - logger.finest("readWebsocket() exiting "+uri); + logger.log(TRACE, "readWebsocket() exiting {0}", uri); } } From ab5d60a999e1f0d00975c5309511540150576906 Mon Sep 17 00:00:00 2001 From: robert engels Date: Wed, 11 Dec 2024 14:22:49 -0600 Subject: [PATCH 03/78] fix issue Firefox websocket upgrade request #12 --- .../java/robaho/net/httpserver/Utils.java | 35 ++++++++++++++++--- .../websockets/WebSocketHandler.java | 9 +++-- .../net/httpserver/StringUtilsTest.java | 22 ++++++++++++ .../WebSocketResponseHandlerTest.java | 2 ++ 4 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 src/test/java/robaho/net/httpserver/StringUtilsTest.java diff --git a/src/main/java/robaho/net/httpserver/Utils.java b/src/main/java/robaho/net/httpserver/Utils.java index 61d28b2..9fe52dc 100644 --- a/src/main/java/robaho/net/httpserver/Utils.java +++ b/src/main/java/robaho/net/httpserver/Utils.java @@ -22,7 +22,6 @@ * or visit www.oracle.com if you need additional information or have any * questions. */ - package robaho.net.httpserver; /** @@ -36,9 +35,9 @@ public class Utils { private static final boolean[] QUOTED_PAIR = new boolean[256]; static { - char[] allowedTokenChars = ("!#$%&'*+-.^_`|~0123456789" + - "abcdefghijklmnopqrstuvwxyz" + - "ABCDEFGHIJKLMNOPQRSTUVWXYZ").toCharArray(); + char[] allowedTokenChars = ("!#$%&'*+-.^_`|~0123456789" + + "abcdefghijklmnopqrstuvwxyz" + + "ABCDEFGHIJKLMNOPQRSTUVWXYZ").toCharArray(); for (char c : allowedTokenChars) { TCHAR[c] = true; } @@ -87,4 +86,32 @@ public static boolean isQuotedStringContent(String token) { } return true; } + + /** + * efficient contains() ignoring case + */ + public static boolean containsIgnoreCase(String src, String what) { + final int length = what.length(); + if (length == 0) { + return true; // Empty string is contained + } + final char firstLo = Character.toLowerCase(what.charAt(0)); + final char firstUp = Character.toUpperCase(what.charAt(0)); + + final int srcLength = src.length(); + + for (int i = srcLength - length; i >= 0; i--) { + // Quick check before calling the more expensive regionMatches() method: + final char ch = src.charAt(i); + if (ch != firstLo && ch != firstUp) { + continue; + } + + if (src.regionMatches(true, i, what, 0, length)) { + return true; + } + } + + return false; + } } diff --git a/src/main/java/robaho/net/httpserver/websockets/WebSocketHandler.java b/src/main/java/robaho/net/httpserver/websockets/WebSocketHandler.java index 434b19e..db6640a 100644 --- a/src/main/java/robaho/net/httpserver/websockets/WebSocketHandler.java +++ b/src/main/java/robaho/net/httpserver/websockets/WebSocketHandler.java @@ -8,6 +8,7 @@ import com.sun.net.httpserver.HttpHandler; import robaho.net.httpserver.Code; +import robaho.net.httpserver.Utils; public abstract class WebSocketHandler implements HttpHandler { @@ -58,11 +59,9 @@ public void handle(HttpExchange exchange) throws IOException { public static boolean isWebsocketRequested(Headers headers) { // check if Upgrade connection - String connection = headers.getFirst(Util.HEADER_CONNECTION); - if (connection == null || !connection.equalsIgnoreCase(Util.HEADER_CONNECTION_VALUE)) { - return false; - } - // check for proper upgrade tyoe + var values = headers.get(Util.HEADER_CONNECTION); + if(values==null || values.stream().filter(s -> Utils.containsIgnoreCase(s, Util.HEADER_CONNECTION_VALUE)).findAny().isEmpty()) return false; + // check for proper upgrade type String upgrade = headers.getFirst(Util.HEADER_UPGRADE); return Util.HEADER_UPGRADE_VALUE.equalsIgnoreCase(upgrade); } diff --git a/src/test/java/robaho/net/httpserver/StringUtilsTest.java b/src/test/java/robaho/net/httpserver/StringUtilsTest.java new file mode 100644 index 0000000..f32c1ce --- /dev/null +++ b/src/test/java/robaho/net/httpserver/StringUtilsTest.java @@ -0,0 +1,22 @@ +package robaho.net.httpserver; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; +import org.testng.annotations.Test; + +public class StringUtilsTest { + @Test + public void TestContainsIgnoreCase() throws IOException { + assertTrue(Utils.containsIgnoreCase("Keep-alive","keep-alive")); + assertTrue(Utils.containsIgnoreCase("Keep-alive, upgrade","Upgrade")); + assertTrue(Utils.containsIgnoreCase("upgrade, keep-alive","Upgrade")); + assertTrue(Utils.containsIgnoreCase("upgrade, keep-alive","upgrade")); + assertFalse(Utils.containsIgnoreCase("Keep-alive, upgrde","Upgrade")); + } + +} \ No newline at end of file diff --git a/src/test/java/robaho/net/httpserver/websockets/WebSocketResponseHandlerTest.java b/src/test/java/robaho/net/httpserver/websockets/WebSocketResponseHandlerTest.java index cd4ac33..b134572 100644 --- a/src/test/java/robaho/net/httpserver/websockets/WebSocketResponseHandlerTest.java +++ b/src/test/java/robaho/net/httpserver/websockets/WebSocketResponseHandlerTest.java @@ -155,6 +155,8 @@ private void testResponseHeader(String key, String expected) { public void testConnectionHeaderHandlesKeepAlive_FixingFirefoxConnectIssue() throws IOException { this.headers.set("connection", "keep-alive, Upgrade"); handler.handle(exchange); + testResponseHeader(Util.HEADER_WEBSOCKET_ACCEPT, "HSmrc0sMlYUkAGmm5OPpG2HaGWk="); + testResponseHeader(Util.HEADER_WEBSOCKET_PROTOCOL, "chat"); } @Test From 1f22354d909dcb9693e3c515abad6762b944813c Mon Sep 17 00:00:00 2001 From: robert engels Date: Wed, 11 Dec 2024 14:30:22 -0600 Subject: [PATCH 04/78] remove gradle wrapper files from repo --- .gitignore | 3 + gradle/wrapper/gradle-wrapper.jar | Bin 63721 -> 0 bytes gradle/wrapper/gradle-wrapper.properties | 7 -- gradlew.bat | 92 ----------------------- 4 files changed, 3 insertions(+), 99 deletions(-) delete mode 100644 gradle/wrapper/gradle-wrapper.jar delete mode 100644 gradle/wrapper/gradle-wrapper.properties delete mode 100644 gradlew.bat diff --git a/.gitignore b/.gitignore index 42f451d..1f9231f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ gradle.properties .vscode .DS_Store fileserver/ +gradlew.bat +gradle/ +gradle/ diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 7f93135c49b765f8051ef9d0a6055ff8e46073d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 63721 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鯊%1Et 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⁢ zv5o9#E7!G0XmdzfsS{efPNi`1b44~SZ4Z8fuX!I}#8g+(wxzQwUT#Xb2(tbY1+EUhGKoT@KEU9Ktl>_0 z%bjDJg;#*gtJZv!-Zs`?^}v5eKmnbjqlvnSzE@_SP|LG_PJ6CYU+6zY6>92%E+ z=j@TZf-iW4(%U{lnYxQA;7Q!b;^brF8n0D>)`q5>|WDDXLrqYU_tKN2>=#@~OE7grMnNh?UOz-O~6 z6%rHy{#h9K0AT+lDC7q4{hw^|q6*Ry;;L%Q@)Ga}$60_q%D)rv(CtS$CQbpq9|y1e zRSrN4;$Jyl{m5bZw`$8TGvb}(LpY{-cQ)fcyJv7l3S52TLXVDsphtv&aPuDk1OzCA z4A^QtC(!11`IsNx_HnSy?>EKpHJWT^wmS~hc^p^zIIh@9f6U@I2 zC=Mve{j2^)mS#U$e{@Q?SO6%LDsXz@SY+=cK_QMmXBIU)j!$ajc-zLx3V60EXJ!qC zi<%2x8Q24YN+&8U@CIlN zrZkcT9yh%LrlGS9`G)KdP(@9Eo-AQz@8GEFWcb7U=a0H^ZVbLmz{+&M7W(nXJ4sN8 zJLR7eeK(K8`2-}j(T7JsO`L!+CvbueT%izanm-^A1Dn{`1Nw`9P?cq;7no+XfC`K(GO9?O^5zNIt4M+M8LM0=7Gz8UA@Z0N+lg+cX)NfazRu z5D)~HA^(u%w^cz+@2@_#S|u>GpB+j4KzQ^&Wcl9f z&hG#bCA(Yk0D&t&aJE^xME^&E-&xGHhXn%}psEIj641H+Nl-}boj;)Zt*t(4wZ5DN z@GXF$bL=&pBq-#vkTkh>7hl%K5|3 z{`Vn9b$iR-SoGENp}bn4;fR3>9sA%X2@1L3aE9yTra;Wb#_`xWwLSLdfu+PAu+o3| zGVnpzPr=ch{uuoHjtw7+_!L_2;knQ!DuDl0R`|%jr+}jFzXtrHIKc323?JO{l&;VF z*L1+}JU7%QJOg|5|Tc|D8fN zJORAg=_vsy{ak|o);@)Yh8Lkcg@$FG3k@ep36BRa^>~UmnRPziS>Z=`Jb2x*Q#`%A zU*i3&Vg?TluO@X0O;r2Jl6LKLUOVhSqg1*qOt^|8*c7 zo(298@+r$k_wQNGHv{|$tW(T8L+4_`FQ{kEW5Jgg{yf7ey4ss_(SNKfz(N9lx&a;< je(UuV8hP?p&}TPdm1I$XmG#(RzlD&B2izSj9sl%y5~4qc diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index b82aa23..0000000 --- a/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/gradlew.bat b/gradlew.bat deleted file mode 100644 index 93e3f59..0000000 --- a/gradlew.bat +++ /dev/null @@ -1,92 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega From 5a10a755b9e0e07b82cf7ed8c44c9f1b6974c8cb Mon Sep 17 00:00:00 2001 From: robert engels Date: Wed, 11 Dec 2024 15:03:10 -0600 Subject: [PATCH 05/78] version 1.0.11 --- README.md | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ecd6781..2a39598 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ The counts can be reset using `/__stats?reset`. The `requests/sec` is calculated io.github.robaho httpserver - 1.0.10 + 1.0.11 ``` ## future work diff --git a/build.gradle b/build.gradle index df7c67b..6a70c27 100644 --- a/build.gradle +++ b/build.gradle @@ -149,7 +149,7 @@ publishing { maven(MavenPublication) { groupId = 'io.github.robaho' artifactId = 'httpserver' - version = "1.0.10" + version = "1.0.11" from components.java From af3fada70f7adcc33ce5bd757ac4625efa4d3bf7 Mon Sep 17 00:00:00 2001 From: robert engels Date: Mon, 30 Dec 2024 17:13:33 -0600 Subject: [PATCH 06/78] Merge branch 'http2' --- build.gradle | 8 +- gradlew | 7 +- install_local.sh | 1 + logging.properties | 2 +- src/main/java/module-info.java | 1 + .../net/httpserver/Http2ExchangeImpl.java | 133 +++++ .../robaho/net/httpserver/HttpConnection.java | 43 +- .../robaho/net/httpserver/ServerConfig.java | 39 +- .../robaho/net/httpserver/ServerImpl.java | 234 ++++++++- .../net/httpserver/http2/HTTP2Connection.java | 470 ++++++++++++++++++ .../net/httpserver/http2/HTTP2ErrorCode.java | 40 ++ .../net/httpserver/http2/HTTP2Exception.java | 30 ++ .../net/httpserver/http2/HTTP2Stream.java | 452 +++++++++++++++++ .../robaho/net/httpserver/http2/Utils.java | 82 +++ .../net/httpserver/http2/frame/BaseFrame.java | 25 + .../http2/frame/ContinuationFrame.java | 28 ++ .../net/httpserver/http2/frame/DataFrame.java | 35 ++ .../net/httpserver/http2/frame/FrameFlag.java | 81 +++ .../httpserver/http2/frame/FrameHeader.java | 108 ++++ .../http2/frame/FrameSerializer.java | 64 +++ .../net/httpserver/http2/frame/FrameType.java | 34 ++ .../httpserver/http2/frame/GoawayFrame.java | 37 ++ .../httpserver/http2/frame/HeadersFrame.java | 172 +++++++ .../http2/frame/NotImplementedFrame.java | 21 + .../net/httpserver/http2/frame/PingFrame.java | 31 ++ .../httpserver/http2/frame/PriorityFrame.java | 39 ++ .../http2/frame/PushPromiseFrame.java | 20 + .../http2/frame/ResetStreamFrame.java | 32 ++ .../http2/frame/SettingIdentifier.java | 52 ++ .../http2/frame/SettingParameter.java | 50 ++ .../httpserver/http2/frame/SettingsFrame.java | 72 +++ .../http2/frame/WindowUpdateFrame.java | 45 ++ .../httpserver/http2/hpack/HPackContext.java | 351 +++++++++++++ .../http2/hpack/HTTP2HeaderField.java | 41 ++ .../httpserver/http2/hpack/HeaderFields.java | 81 +++ .../net/httpserver/http2/hpack/Huffman.java | 85 ++++ src/main/resources/huffman_codes_rfc7541.txt | 257 ++++++++++ src/test/extras/SimpleFileServer.java | 28 +- src/test/java/FileServerHandler.java | 28 +- 39 files changed, 3295 insertions(+), 64 deletions(-) create mode 100755 install_local.sh create mode 100644 src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java create mode 100644 src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java create mode 100644 src/main/java/robaho/net/httpserver/http2/HTTP2ErrorCode.java create mode 100644 src/main/java/robaho/net/httpserver/http2/HTTP2Exception.java create mode 100644 src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java create mode 100644 src/main/java/robaho/net/httpserver/http2/Utils.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/BaseFrame.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/ContinuationFrame.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/DataFrame.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/FrameFlag.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/FrameHeader.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/FrameSerializer.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/FrameType.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/GoawayFrame.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/HeadersFrame.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/NotImplementedFrame.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/PingFrame.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/PriorityFrame.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/PushPromiseFrame.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/ResetStreamFrame.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/SettingIdentifier.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/SettingParameter.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/SettingsFrame.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/WindowUpdateFrame.java create mode 100644 src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java create mode 100644 src/main/java/robaho/net/httpserver/http2/hpack/HTTP2HeaderField.java create mode 100644 src/main/java/robaho/net/httpserver/http2/hpack/HeaderFields.java create mode 100644 src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java create mode 100644 src/main/resources/huffman_codes_rfc7541.txt diff --git a/build.gradle b/build.gradle index 6a70c27..45cf3e6 100644 --- a/build.gradle +++ b/build.gradle @@ -25,6 +25,7 @@ tasks.withType(Test) { jvmArgs += "--add-opens=jdk.httpserver/com.sun.net.httpserver=ALL-UNNAMED" systemProperty("java.util.logging.config.file","logging.properties") systemProperty("com.sun.net.httpserver.HttpServerProvider","robaho.net.httpserver.DefaultHttpServerProvider") + systemProperty("robaho.net.httpserver.http2MaxConcurrentStreams","5000") // systemProperty("javax.net.debug","ssl:handshake:verbose:keymanager:trustmanager") } @@ -128,8 +129,11 @@ task runSimpleFileServer(type: Test) { classpath sourceSets.test.runtimeClasspath main "SimpleFileServer" systemProperties = props - args = ['fileserver','8888','fileserver/logfile.txt'] - // debug true + args = ['fileserver','8080','fileserver/logfile.txt'] + debugOptions { + enabled = true + suspend = false + } } } } diff --git a/gradlew b/gradlew index 1aa94a4..f5feea6 100755 --- a/gradlew +++ b/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -84,7 +86,8 @@ done # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum diff --git a/install_local.sh b/install_local.sh new file mode 100755 index 0000000..abce590 --- /dev/null +++ b/install_local.sh @@ -0,0 +1 @@ +mvn install:install-file -Dfile=build/libs/httpserver.jar -DgroupId=io.github.robaho -DartifactId=httpserver -Dversion=$1 -Dpackaging=jar -DgeneratePom=true \ No newline at end of file diff --git a/logging.properties b/logging.properties index 8afc3dd..d273627 100644 --- a/logging.properties +++ b/logging.properties @@ -3,4 +3,4 @@ handlers = java.util.logging.ConsoleHandler java.util.logging.ConsoleHandler.level = ALL java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter java.util.logging.SimpleFormatter.format=[%1$tF %1$tT] [%4$-7s] [%2$s] %5$s %6$s %n -robaho.net.level=FINEST \ No newline at end of file +robaho.net.level=INFO \ No newline at end of file diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index c7c93e4..6d0ca7d 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -2,6 +2,7 @@ exports robaho.net.httpserver; exports robaho.net.httpserver.extras; exports robaho.net.httpserver.websockets; + exports robaho.net.httpserver.http2; requires transitive java.logging; requires transitive java.net.http; diff --git a/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java b/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java new file mode 100644 index 0000000..30c41ff --- /dev/null +++ b/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java @@ -0,0 +1,133 @@ +package robaho.net.httpserver; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.URI; + +import com.sun.net.httpserver.Headers; +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpPrincipal; + +import robaho.net.httpserver.http2.HTTP2Stream; + +public class Http2ExchangeImpl extends HttpExchange { + private final Headers request; + private final Headers response; + private final InputStream in; + private final OutputStream out; + private final URI uri; + private final String method; + private final HttpContext ctx; + private final HTTP2Stream stream; + private HttpPrincipal principal; + private int responseCode; + + public Http2ExchangeImpl(HTTP2Stream stream, URI uri, String method, HttpContext ctx, Headers request, Headers response, InputStream in, OutputStream out) { + this.request = request; + this.response = response; + this.stream = stream; + this.in = in; + this.out = out; + this.uri = uri; + this.method = method; + this.ctx = ctx; + } + + @Override + public Headers getRequestHeaders() { + return request; + } + + @Override + public Headers getResponseHeaders() { + return response; + } + + @Override + public InputStream getRequestBody() { + return in; + } + + @Override + public OutputStream getResponseBody() { + return out; + } + + @Override + public URI getRequestURI() { + return uri; + } + + @Override + public String getRequestMethod() { + return method; + } + + @Override + public HttpContext getHttpContext() { + return ctx; + } + + @Override + public void close() { + stream.close(); + } + + @Override + public void sendResponseHeaders(int rCode, long responseLength) throws IOException { + if(responseLength>0) { + response.set("Content-Length", Long.toString(responseLength)); + } else if(responseLength==0) { + // no chunked encoding so just ignore + } else { + // -1 means no data will be sent, so should set end of stream + // response.set("Content-Length", Long.toString(responseLength)); + } + response.set(":status",Long.toString(rCode)); + responseCode = rCode; + stream.writeResponseHeaders(); + } + + @Override + public InetSocketAddress getRemoteAddress() { + return stream.getRemoteAddress(); + } + + @Override + public InetSocketAddress getLocalAddress() { + return stream.getLocalAddress(); + } + + @Override + public int getResponseCode() { + return responseCode; + } + + @Override + public String getProtocol() { + return "HTTP/2"; + } + + @Override + public Object getAttribute(String name) { + return ctx.getAttributes().get(name); + } + + @Override + public void setAttribute(String name, Object value) { + ctx.getAttributes().put(name, value); + } + + @Override + public void setStreams(InputStream i, OutputStream o) { + throw new UnsupportedOperationException("Not implemented yet"); + } + + @Override + public HttpPrincipal getPrincipal() { + return principal; + } +} diff --git a/src/main/java/robaho/net/httpserver/HttpConnection.java b/src/main/java/robaho/net/httpserver/HttpConnection.java index 4375a8a..a13e786 100644 --- a/src/main/java/robaho/net/httpserver/HttpConnection.java +++ b/src/main/java/robaho/net/httpserver/HttpConnection.java @@ -32,7 +32,9 @@ import java.io.OutputStream; import java.lang.System.Logger; import java.lang.System.Logger.Level; +import java.net.InetSocketAddress; import java.net.Socket; +import java.util.concurrent.atomic.AtomicLong; import javax.net.ssl.SSLSession; import javax.net.ssl.SSLSocket; @@ -42,7 +44,7 @@ * one of these is hung from the selector attachment and is used to locate * everything from that. */ -class HttpConnection { +public class HttpConnection { private static final Logger logger = System.getLogger("robaho.net.httpserver"); HttpContextImpl context; @@ -57,27 +59,40 @@ class HttpConnection { volatile long lastActivityTime; volatile boolean noActivity; volatile boolean inRequest; - volatile long requestCount; + + public AtomicLong requestCount = new AtomicLong(); + private final String connectionId; + + public boolean isClosed() { + return closed; + } + + HttpConnection(Socket socket) throws IOException { + this.socket = socket; + this.is = new NoSyncBufferedInputStream(new ActivityTimerInputStream(socket.getInputStream())); + this.os = new NoSyncBufferedOutputStream(new ActivityTimerOutputStream(socket.getOutputStream())); + connectionId = "["+socket.getLocalPort()+"."+socket.getPort()+"]"; + } SSLSession getSSLSession() { return (socket instanceof SSLSocket ssl) ? ssl.getHandshakeSession() : null; } + public boolean isSSL() { + return socket instanceof SSLSocket; + } + @Override public String toString() { - final var sb = new StringBuilder(HttpConnection.class.getSimpleName()); - if (socket != null) { - sb.append(" ("); - sb.append(socket); - sb.append(")"); - } - return sb.toString(); + return connectionId; } - HttpConnection(Socket socket) throws IOException { - this.socket = socket; - this.is = new NoSyncBufferedInputStream(new ActivityTimerInputStream(socket.getInputStream())); - this.os = new NoSyncBufferedOutputStream(new ActivityTimerOutputStream(socket.getOutputStream())); + public InetSocketAddress getRemoteAddress() { + return (InetSocketAddress) socket.getRemoteSocketAddress(); + } + + public InetSocketAddress getLocalAddress() { + return (InetSocketAddress) socket.getLocalSocketAddress(); } void setContext(HttpContextImpl ctx) { @@ -95,7 +110,7 @@ synchronized void close() { closed = true; if (socket != null) { - if(requestCount==0) { + if(requestCount.get()==0) { logger.log(Level.WARNING, "closing connection: remote "+socket.getRemoteSocketAddress() + " with 0 requests"); } else { logger.log(Level.TRACE, () -> "Closing connection: remote " + socket.getRemoteSocketAddress()); diff --git a/src/main/java/robaho/net/httpserver/ServerConfig.java b/src/main/java/robaho/net/httpserver/ServerConfig.java index 2b5fed4..eff3d9e 100644 --- a/src/main/java/robaho/net/httpserver/ServerConfig.java +++ b/src/main/java/robaho/net/httpserver/ServerConfig.java @@ -33,7 +33,7 @@ */ @SuppressWarnings("removal") -class ServerConfig { +public class ServerConfig { private static final int DEFAULT_IDLE_TIMER_SCHEDULE_MILLIS = 10000; // 10 sec. @@ -50,6 +50,10 @@ class ServerConfig { private static final int DEFAULT_MAX_REQ_HEADERS = 200; private static final long DEFAULT_DRAIN_AMOUNT = 64 * 1024; + private static final int DEFAULT_HTTP2_MAX_FRAME_SIZE = 16384; + private static final int DEFAULT_HTTP2_INITIAL_WINDOW_SIZE = 65535; + private static final int DEFAULT_HTTP2_MAX_CONCURRENT_STREAMS = -1; // use -1 for no limit + private static long idleTimerScheduleMillis; private static long idleIntervalMillis; // The maximum number of bytes to drain from an inputstream @@ -71,6 +75,12 @@ class ServerConfig { // the value of the TCP_NODELAY socket-level option private static boolean noDelay; + private static boolean http2OverSSL; + private static boolean http2OverNonSSL; + private static int http2MaxFrameSize; + private static int http2InitialWindowSize; + private static int http2MaxConcurrentStreams; + static { java.security.AccessController.doPrivileged( new PrivilegedAction() { @@ -126,6 +136,14 @@ public Void run() { noDelay = Boolean.getBoolean(pkg + ".nodelay"); + http2OverSSL = Boolean.getBoolean(pkg + ".http2OverSSL"); + http2OverNonSSL = Boolean.getBoolean(pkg + ".http2OverNonSSL"); + + http2MaxFrameSize = Integer.getInteger(pkg + ".http2MaxFrameSize", DEFAULT_HTTP2_MAX_FRAME_SIZE); + http2InitialWindowSize = Integer.getInteger(pkg + ".http2InitialWindowSize", DEFAULT_HTTP2_INITIAL_WINDOW_SIZE); + + http2MaxConcurrentStreams = Integer.getInteger(pkg + ".http2MaxConcurrentStreams", DEFAULT_HTTP2_MAX_CONCURRENT_STREAMS); + return null; } }); @@ -216,4 +234,23 @@ static long getReqRspTimerScheduleMillis() { static boolean noDelay() { return noDelay; } + + public static boolean http2OverSSL() { + return http2OverSSL; + } + public static boolean http2OverNonSSL() { + return http2OverNonSSL; + } + public static int http2MaxFrameSize() { + return http2MaxFrameSize; + } + public static int http2InitialWindowSize() { + return http2InitialWindowSize; + } + /** + * @return the maximum number of concurrent streams per connection, or -1 for no limit + */ + public static int http2MaxConcurrentStreams() { + return http2MaxConcurrentStreams; + } } diff --git a/src/main/java/robaho/net/httpserver/ServerImpl.java b/src/main/java/robaho/net/httpserver/ServerImpl.java index 7440270..b261e35 100644 --- a/src/main/java/robaho/net/httpserver/ServerImpl.java +++ b/src/main/java/robaho/net/httpserver/ServerImpl.java @@ -24,6 +24,7 @@ */ package robaho.net.httpserver; +import java.io.EOFException; import static java.nio.charset.StandardCharsets.ISO_8859_1; import java.io.IOException; @@ -64,6 +65,11 @@ import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpsConfigurator; +import robaho.net.httpserver.http2.HTTP2Connection; +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.HTTP2Stream; + /** * Provides implementation for both HTTP and HTTPS */ @@ -126,18 +132,16 @@ class ServerImpl { this.protocol = protocol; this.wrapper = wrapper; + socket = new ServerSocket(); + this.logger = System.getLogger("robaho.net.httpserver."+System.identityHashCode(this)); - java.util.logging.Logger.getLogger(this.logger.getName()).setFilter(new java.util.logging.Filter(){ - @Override - public boolean isLoggable(LogRecord record) { - record.setMessage("["+protocol+":"+socket.getLocalPort()+"] "+record.getMessage()); - return true; - } + java.util.logging.Logger.getLogger(this.logger.getName()).setFilter((LogRecord record) -> { + record.setMessage("["+protocol+":"+socket.getLocalPort()+"] "+record.getMessage()); + return true; }); https = protocol.equalsIgnoreCase("https"); contexts = new ContextList(); - socket = new ServerSocket(); if (addr != null) { socket.bind(addr, backlog); bound = true; @@ -147,6 +151,7 @@ public boolean isLoggable(LogRecord record) { timer = new Timer("connection-cleaner", true); timer.schedule(new ConnectionCleanerTask(), IDLE_TIMER_TASK_SCHEDULE, IDLE_TIMER_TASK_SCHEDULE); timer.schedule(ActivityTimer.createTask(),750,750); + timer.schedule(Http2Exchange.createTask(),1000,1000); logger.log(Level.DEBUG, "HttpServer created " + protocol + " " + addr); if(Boolean.getBoolean("robaho.net.httpserver.EnableStats")) { createContext("/__stats",new StatsHandler()); @@ -377,21 +382,50 @@ public void run() { s.setTcpNoDelay(true); } + boolean http2 = false; + 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); + sslSocket.setHandshakeApplicationProtocolSelector((_sslSocket, protocols) -> { + if (protocols.contains("h2") && ServerConfig.http2OverSSL()) { + return "h2"; + } else { + return "http/1.1"; + } + }); sslSocket.setUseClientMode(false); + // 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 = new HttpConnection(s); + HttpConnection c; + try { + c = new HttpConnection(s); + } catch (IOException e) { + logger.log(Level.WARNING, "Failed to create HttpConnection", e); + continue; + } try { allConnections.add(c); - Exchange t = new Exchange(protocol, c); - executor.execute(t); + 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); @@ -418,6 +452,159 @@ private void closeConnection(HttpConnection conn) { allConnections.remove(conn); } + /* used to link to 2 or more Filter.Chains together */ + class LinkHandler implements HttpHandler { + + Filter.Chain nextChain; + + LinkHandler(Filter.Chain nextChain) { + this.nextChain = nextChain; + } + + @Override + public void handle(HttpExchange exchange) throws IOException { + nextChain.doFilter(exchange); + } + } + + class Http2Exchange implements Runnable,HTTP2Connection.StreamHandler { + final HttpConnection connection; + final HTTP2Connection http2; + final String protocol; + + private static final Set allHttp2Exchanges = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + Http2Exchange(String protocol, HttpConnection conn) throws IOException { + this.connection = conn; + this.protocol = protocol; + + http2 = new HTTP2Connection(conn, connection.getInputStream(), connection.getOutputStream(), this); + } + + static TimerTask createTask() { + return new TimerTask() { + @Override + public void run() { + long now = System.currentTimeMillis(); + for(var exchange : allHttp2Exchanges) { + if(exchange.connection.lastActivityTime + 1000 < now) { + try { + exchange.http2.sendPing(); + } catch (IOException ex) { + } + } + } + } + }; + } + + @Override + public void run() { + allHttp2Exchanges.add(this); + + try { + if(!http2.hasProperPreface()) { + http2.sendGoAway(HTTP2ErrorCode.PROTOCOL_ERROR); + logger.log(Level.WARNING, "ServerImpl HTTP2 preface failed"); + return; + } + http2.sendMySettings(); + http2.handle(); + } catch (HTTP2Exception ex) { + logger.log(Level.WARNING, "ServerImpl http2 protocol exception "+http2, ex.getMessage()); + } catch (EOFException ex) { + logger.log(Level.DEBUG, "end of stream "+http2); + } catch (Exception ex) { + logger.log(Level.WARNING, "ServerImpl unexpected exception handling http2 connection "+http2, ex); + } finally { + try { + logger.log(Level.DEBUG, "closing HTTP2 connection and streams "+http2); + closeConnection(connection); + http2.close(); + } catch (Throwable t) { + logger.log(Level.WARNING, "error closing http2 connection "+http2, t); + } + allHttp2Exchanges.remove(this); + } + } + + @Override + public Executor getExecutor() { + return ServerImpl.this.executor; + } + + @Override + public void handleStream(HTTP2Stream stream,InputStream in, OutputStream out) throws IOException { + connection.requestCount.incrementAndGet(); + requestCount.incrementAndGet(); + + var request = stream.getRequestHeaders(); + var response = stream.getResponseHeaders(); + + String scheme = https ? "https" : "http"; + String authority = request.getFirst(":authority"); + String path = request.getFirst(":path"); + String query = request.getFirst(":query"); + + logger.log(Level.TRACE, () -> "http2 stream started "+stream.toString()); + + if (authority == null || path == null) { + throw new IOException("Invalid HTTP/2 headers: missing :authority or :path"); + } + + StringBuilder uriBuilder = new StringBuilder(); + uriBuilder.append(scheme).append("://").append(authority).append(path); + if (query != null) { + uriBuilder.append("?").append(query); + } + + request.add("Host",authority); + + URI uri; + try { + uri = new URI(uriBuilder.toString()); + } catch (URISyntaxException e) { + throw new IOException("Invalid URI syntax", e); + } + String method = request.getFirst(":method"); + if (method == null) { + throw new IOException("Invalid HTTP/2 headers: missing :method"); + } + + // Validate Transfer-Encoding headers for HTTP/2 + List transferEncoding = request.get("Transfer-encoding"); + if (transferEncoding != null && !transferEncoding.isEmpty()) { + if (transferEncoding.size() > 1 || !transferEncoding.get(0).equalsIgnoreCase("chunked")) { + throw new IOException("Unsupported Transfer-Encoding value"); + } + } + + String uriPath = Optional.ofNullable(uri.getPath()).orElse("/"); + HttpContextImpl ctx = contexts.findContext(protocol, uriPath); + if (ctx == null) { + logger.log(Level.DEBUG, "No context found for request "+uriPath+", rejecting as not found"); + response.set(":status","404"); + stream.writeResponseHeaders(); + out.close(); + return; + } + + logger.log(Level.DEBUG,() -> "http2 request on "+connection+" "+method+" for "+uri); + + final List sf = ctx.getSystemFilters(); + final List uf = ctx.getFilters(); + + final Filter.Chain sc = new Filter.Chain(sf, ctx.getHandler()); + final Filter.Chain uc = new Filter.Chain(uf, new LinkHandler(sc)); + + if (https) { + uc.doFilter(new Http2ExchangeImpl(stream,uri,method,ctx,request,response,in,out)); + } else { + uc.doFilter(new Http2ExchangeImpl(stream,uri,method,ctx,request,response,in,out)); + } + } + } + /* per exchange task */ class Exchange implements Runnable { final HttpConnection connection; @@ -480,8 +667,17 @@ private void runPerRequest() throws IOException { logger.log(Level.TRACE,"reading request"); connection.inRequest = false; + Request req = new Request(rawin, rawout); final String requestLine = req.requestLine(); + + if("PRI * HTTP/2.0".equals(requestLine) && ServerConfig.http2OverNonSSL()) { + logger.log(Level.DEBUG,"found http2 request on non-SSL assuming prior knowledge"); + Http2Exchange exchange = new Http2Exchange(protocol, connection); + exchange.run(); + return; + } + connection.inRequest = true; if (requestLine == null) { @@ -490,7 +686,7 @@ private void runPerRequest() throws IOException { closeConnection(connection); return; } - connection.requestCount++; + connection.requestCount.incrementAndGet(); requestCount.incrementAndGet(); logger.log(Level.DEBUG, () -> "Exchange request line: "+ requestLine); @@ -520,6 +716,7 @@ private void runPerRequest() throws IOException { start = space + 1; String version = requestLine.substring(start); Headers headers = req.headers(); + /* check key for illegal characters, impossible since Headers class validates on mutation */ // for (var k : headers.keySet()) { // if (!isValidName(k)) { @@ -646,21 +843,6 @@ private void runPerRequest() throws IOException { } } - /* used to link to 2 or more Filter.Chains together */ - class LinkHandler implements HttpHandler { - - Filter.Chain nextChain; - - LinkHandler(Filter.Chain nextChain) { - this.nextChain = nextChain; - } - - @Override - public void handle(HttpExchange exchange) throws IOException { - nextChain.doFilter(exchange); - } - } - void reject(int code, String requestStr, String message) { logReply(code, requestStr, message); sendReply( diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java new file mode 100644 index 0000000..26107d4 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java @@ -0,0 +1,470 @@ +package robaho.net.httpserver.http2; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import com.sun.net.httpserver.Headers; + +import robaho.net.httpserver.HttpConnection; +import robaho.net.httpserver.ServerConfig; +import robaho.net.httpserver.http2.hpack.HPackContext; +import robaho.net.httpserver.http2.hpack.HTTP2HeaderField; +import robaho.net.httpserver.http2.hpack.HeaderFields; +import robaho.net.httpserver.http2.frame.BaseFrame; +import robaho.net.httpserver.http2.frame.ContinuationFrame; +import robaho.net.httpserver.http2.frame.DataFrame; +import robaho.net.httpserver.http2.frame.FrameFlag; +import robaho.net.httpserver.http2.frame.FrameHeader; +import robaho.net.httpserver.http2.frame.FrameSerializer; +import robaho.net.httpserver.http2.frame.FrameType; +import robaho.net.httpserver.http2.frame.GoawayFrame; +import robaho.net.httpserver.http2.frame.HeadersFrame; +import robaho.net.httpserver.http2.frame.PingFrame; +import robaho.net.httpserver.http2.frame.ResetStreamFrame; +import robaho.net.httpserver.http2.frame.SettingIdentifier; +import robaho.net.httpserver.http2.frame.SettingParameter; +import robaho.net.httpserver.http2.frame.SettingsFrame; +import robaho.net.httpserver.http2.frame.WindowUpdateFrame; + +public class HTTP2Connection { + + static final String PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"; + static final String PARTIAL_PREFACE = "\r\nSM\r\n\r\n"; + + final private InputStream inputStream; + final OutputStream outputStream; + + private int lastSeenStreamId = 0; + + final ConcurrentMap http2Streams = new ConcurrentHashMap<>(); + private final Set previousStreams = new HashSet(); + + private final HashMap remoteSettings = new HashMap<>(); + private final HashMap localSettings = new HashMap<>(); + + private final StreamHandler handler; + + private final Lock lock = new ReentrantLock(); + + final HttpConnection httpConnection; + + final Logger logger; + final HPackContext hpack = new HPackContext(); + + final AtomicLong sendWindow = new AtomicLong(65535); + final AtomicInteger receiveWindow = new AtomicInteger(65535); + + private int maxConcurrentStreams = -1; + private int highNumberStreams = 0; + + /** + * Constructor to instantiate HTTP2Connection object + * + * @param input + * HTTP2Client passes the ExBufferedInputStream + * @param output + */ + public HTTP2Connection(HttpConnection httpConnection,InputStream input, OutputStream output, StreamHandler handler) { + this.httpConnection = httpConnection; + this.inputStream = input; + this.outputStream = output; + this.handler = handler; + this.logger = System.getLogger("robaho.net.httpserver.http2"); + + localSettings.put(SettingIdentifier.SETTINGS_MAX_FRAME_SIZE,new SettingParameter(SettingIdentifier.SETTINGS_MAX_FRAME_SIZE,ServerConfig.http2MaxFrameSize())); + localSettings.put(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE,new SettingParameter(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE,ServerConfig.http2InitialWindowSize())); + if(ServerConfig.http2MaxConcurrentStreams()!=-1) { + localSettings.put(SettingIdentifier.SETTINGS_MAX_CONCURRENT_STREAMS,new SettingParameter(SettingIdentifier.SETTINGS_MAX_CONCURRENT_STREAMS,ServerConfig.http2MaxConcurrentStreams())); + } + logger.log(Level.DEBUG,"opened http2 connection "+httpConnection+", max concurrent streams "+ServerConfig.http2MaxConcurrentStreams()); + } + + @Override + public String toString() { + return "{" + httpConnection +", streams=" + http2Streams.size()+", high "+highNumberStreams+"}"; + } + + public void close() { + for (HTTP2Stream stream : http2Streams.values()) { + stream.close(); + } + } + + public HashMap getRemoteSettings() { + return remoteSettings; + } + + public HashMap getLocalSettings() { + return localSettings; + } + + /** + * Function to validate the PREFACE received on the input stream from the + * remote system + * + * @return true if preface is valid + * @throws IOException + */ + public boolean hasProperPreface() throws IOException { + String preface_match = (httpConnection.isSSL()) ? PREFACE : PARTIAL_PREFACE; + byte[] preface = new byte[preface_match.length()]; + inputStream.read(preface); + String prefaceStr = new String(preface, 0, preface.length); + return prefaceStr.equals(preface_match); + } + + void lock() { + lock.lock(); + } + void unlock() { + lock.unlock(); + } + + public boolean isClosed() { + return httpConnection.isClosed(); + } + + public void handle() throws Exception { + try { + processFrames(); + } catch (HTTP2Exception e) { + logger.log(Level.DEBUG,"exception on http2 connection",e); + sendGoAway(e.getErrorCode()); + throw e; + } + } + + private void processFrames() throws Exception { + boolean inHeaders = false; + int openStreamId = 0; + + List headerBlockFragments = new ArrayList(); + + // main HTTP2 + while (!httpConnection.isClosed()) { + BaseFrame frame = FrameSerializer.deserialize(inputStream); + // System.out.println("Received frame: " + frame.getHeader()); + + int streamId = frame.getHeader().getStreamIdentifier(); + if (streamId != 0 && streamId % 2 == 0) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"invalid stream id " + streamId+ " on type " + frame.getHeader().getType()); + } + + // rfc7540 (section 6.5): SETTINGS frames always apply to a + // connection, never a single stream. + + switch (frame.getHeader().getType()) { + case SETTINGS: + if (frame.getHeader().getFlags().contains(FrameFlag.ACK)) { + if(ServerConfig.http2MaxConcurrentStreams()!=-1) { + // cannot set this until it's been acked + maxConcurrentStreams = http2Streams.size()+ServerConfig.http2MaxConcurrentStreams(); + } + continue; + } else { + updateRemoteSettings((SettingsFrame) frame); + sendSettingsAck(); + } + continue; + case GOAWAY: + GoawayFrame goaway = (GoawayFrame) frame; + if(goaway.errorCode==HTTP2ErrorCode.NO_ERROR) { + continue; + } + throw new IOException("received GOAWAY from remote "+goaway.errorCode); + case PING: + if (!frame.getHeader().getFlags().contains(FrameFlag.ACK)) { + sendPingAck((PingFrame) frame); + } + continue; + case WINDOW_UPDATE: + if (frame.getHeader().getStreamIdentifier()== 0) { + int windowSizeIncrement = ((WindowUpdateFrame)frame).getWindowSizeIncrement(); + sendWindow.addAndGet(windowSizeIncrement); + logger.log(Level.DEBUG,"received connection window update "+windowSizeIncrement+", new size "+sendWindow.get()); + if(sendWindow.get() > 2147483647) { + throw new HTTP2Exception(HTTP2ErrorCode.FLOW_CONTROL_ERROR,"maximum window size exceeded"); + } + continue; + } + break; + case NOT_IMPLEMENTED: + if(inHeaders) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"NOT_IMPLEMENTED frame received while headers being received"); + } + if (frame.getHeader().getStreamIdentifier() == 0) { + continue; + } + break; + case DATA: + DataFrame dataFrame = (DataFrame) frame; + if(receiveWindow.addAndGet(-dataFrame.body.length)<=0) { + sendWindowUpdate(); + } + if(inHeaders) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"DATA frame received while headers being received"); + } + break; + case HEADERS: + if(inHeaders) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"HEADERS frame received on open stream"); + } + if(streamId < lastSeenStreamId) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"HEADERS frame received out of order"); + } + var stream = http2Streams.get(streamId); + if(stream!=null) { + if(!stream.isOpen() || stream.isHalfClosed()) { + throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED,"HEADERS frame received on already closed stream"); + } else { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"HEADERS frame received on already established stream"); + } + } + HeadersFrame headersFrame = (HeadersFrame) frame; + headerBlockFragments.add(headersFrame.getHeaderBlock()); + if(!headersFrame.getHeader().getFlags().contains(FrameFlag.END_HEADERS)) { + inHeaders = true; + openStreamId = streamId; + continue; + } + break; + case CONTINUATION: + if(inHeaders && frame.getHeader().getStreamIdentifier() != openStreamId) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"HEADERS frame received on open stream"); + } + if(!inHeaders) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"CONTINUATION frame received on closed stream"); + } + ContinuationFrame continuationFrame = (ContinuationFrame) frame; + headerBlockFragments.add(continuationFrame.getHeaderBlock()); + if(!continuationFrame.getHeader().getFlags().contains(FrameFlag.END_HEADERS)) { + continue; + } + break; + case PRIORITY: + if(inHeaders) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"PRIORITY frame received during headers receive"); + } + if(streamId == 0) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"PRIORITY frame received on stream 0"); + } + // stream priority is ignore for now + continue; + case RST_STREAM: + ResetStreamFrame resetFrame = (ResetStreamFrame) frame; + if(resetFrame.errorCode==HTTP2ErrorCode.NO_ERROR) { + continue; + } + if(!http2Streams.containsKey(streamId)) { + if(previousStreams.contains(streamId)) continue; + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"RST_STREAM frame received on non-existent stream"); + } + break; + } + + // we can only get here if we have a complete set of headers + + HTTP2Stream targetStream = null; + + // check if streamID is already present in the hashmap, i.e., a + // already established stream + + if (http2Streams.containsKey(streamId)) { + targetStream = http2Streams.get(streamId); + } else if (lastSeenStreamId < streamId) { + int currentSize = http2Streams.size(); + if(maxConcurrentStreams!=-1 && currentSize>=maxConcurrentStreams) { + throw new HTTP2Exception(HTTP2ErrorCode.REFUSED_STREAM); + } + highNumberStreams = Math.max(highNumberStreams,currentSize); + byte[] headerBlock = Utils.combineByteArrays(headerBlockFragments); + HeaderFields fields = new HeaderFields(); + fields.addAll(hpack.decodeFieldSegments(headerBlock)); + // streamID is not present and has to be greater than all + // the stream IDs present + fields.validate(); + Headers requestHeaders = new Headers(); + for(HTTP2HeaderField field : fields) { + if(field.value==null) { + logger.log(Level.TRACE,() -> "ignoring null header for "+field.getName()); + } else { + requestHeaders.add(field.name,field.value); + } + } + headerBlockFragments.clear(); + inHeaders = false; + targetStream = new HTTP2Stream(streamId, this, requestHeaders, handler); + http2Streams.put(streamId, targetStream); + previousStreams.add(streamId); + lastSeenStreamId = streamId; + } else { + if(previousStreams.contains(streamId)) { + throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED,"stream "+streamId+" is closed"); + } + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"Stream ID not in order"); + } + + if (targetStream != null) { + targetStream.processFrame(frame); + } + } + } + + public void updateRemoteSettings(SettingsFrame remoteSettingFrame) throws HTTP2Exception { + for (SettingParameter parameter : remoteSettingFrame.getSettingParameters()) { + if(parameter.identifier == SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE) { + if(parameter.value > 2147483647) { + throw new HTTP2Exception(HTTP2ErrorCode.FLOW_CONTROL_ERROR,"Invalid value for SETTINGS_INITIAL_WINDOW_SIZE "+parameter.value); + } + logger.log(Level.DEBUG,() -> "received initial window size of "+parameter.value); + } + if(parameter.identifier == SettingIdentifier.SETTINGS_MAX_FRAME_SIZE) { + logger.log(Level.DEBUG,() -> "received max frame size "+parameter.value); + } + getRemoteSettings().put(parameter.identifier, parameter); + } + } + + public void sendSettingsAck() throws IOException { + lock(); + try { + SettingsFrame frame = new SettingsFrame(); + frame.writeTo(outputStream); + outputStream.flush(); + } finally { + unlock(); + logger.log(Level.TRACE,() -> "Sent Settings Ack"); + } + } + public void sendMySettings() throws IOException { + lock(); + try { + FrameHeader header = new FrameHeader(0, FrameType.SETTINGS, EnumSet.noneOf(FrameFlag.class), 0); + SettingsFrame frame = new SettingsFrame(header); + for(var setting : localSettings.values()) { + frame.getSettingParameters().add(setting); + } + frame.writeTo(outputStream); + outputStream.flush(); + } finally { + unlock(); + logger.log(Level.TRACE,() -> "Sent My Settings"); + } + } + public void sendWindowUpdate() throws IOException { + lock(); + try { + receiveWindow.addAndGet(65535); + FrameHeader header = new FrameHeader(4, FrameType.WINDOW_UPDATE, EnumSet.noneOf(FrameFlag.class), 0); + WindowUpdateFrame frame = new WindowUpdateFrame(header); + frame.writeTo(outputStream); + Utils.writeBinary(outputStream, 65535, 4); + outputStream.flush(); + } finally { + unlock(); + logger.log(Level.TRACE,() -> "Sent My Settings"); + } + } + + InetSocketAddress getRemoteAddress() { + return (InetSocketAddress) httpConnection.getRemoteAddress(); + } + InetSocketAddress getLocalAddress() { + return (InetSocketAddress) httpConnection.getLocalAddress(); + } + + public void sendGoAway(HTTP2ErrorCode errorCode) throws IOException { + FrameHeader header = new FrameHeader(8, FrameType.GOAWAY, EnumSet.noneOf(FrameFlag.class), 0); + lock(); + try { + header.writeTo(outputStream); + Utils.writeBinary(outputStream, lastSeenStreamId, 4); + Utils.writeBinary(outputStream, errorCode.value, 4); + outputStream.flush(); + } finally { + unlock(); + logger.log(Level.TRACE,() -> "Sent GoAway "+errorCode); + } + } + public void sendResetStream(HTTP2ErrorCode errorCode,int streamId) throws IOException { + FrameHeader header = new FrameHeader(4, FrameType.RST_STREAM, EnumSet.noneOf(FrameFlag.class), streamId); + lock(); + try { + header.writeTo(outputStream); + Utils.writeBinary(outputStream, errorCode.value, 4); + outputStream.flush(); + } finally { + unlock(); + logger.log(Level.TRACE,() -> "Sent Reset Stream "+streamId); + } + } + + public void sendPing() throws IOException { + FrameHeader header = new FrameHeader(8, FrameType.PING, EnumSet.noneOf(FrameFlag.class), 0); + lock(); + try { + header.writeTo(outputStream); + Utils.writeBinary(outputStream, 0, 4); + Utils.writeBinary(outputStream, 0, 4); + outputStream.flush(); + } finally { + unlock(); + logger.log(Level.TRACE,"Sent Ping"); + } + } + private void sendPingAck(PingFrame frame) throws IOException { + lock(); + try { + FrameHeader header = new FrameHeader(frame.body.length, FrameType.PING, EnumSet.of(FrameFlag.ACK), 0); + header.writeTo(outputStream); + outputStream.write(frame.body); + outputStream.flush(); + } finally { + unlock(); + logger.log(Level.TRACE,"Sent Ping Ack"); + } + } + + public static interface StreamHandler { + void handleStream(HTTP2Stream stream,InputStream in,OutputStream out) throws IOException; + Executor getExecutor(); + } + + public static void readFully(InputStream inputStream, byte[] buffer) throws IOException { + if(buffer.length==0) return; + + int bytesRead = 0; + int offset = 0; + int length = buffer.length; + while (bytesRead != -1 && offset < length) { + bytesRead = inputStream.read(buffer, offset, length - offset); + if (bytesRead != -1) { + offset += bytesRead; + } + } + if (offset==0) { + throw new EOFException("end of stream detected"); + } + if (offset < length) { + throw new IOException("failed to read the full buffer"); + } + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2ErrorCode.java b/src/main/java/robaho/net/httpserver/http2/HTTP2ErrorCode.java new file mode 100644 index 0000000..15b63fc --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2ErrorCode.java @@ -0,0 +1,40 @@ +package robaho.net.httpserver.http2; + +public enum HTTP2ErrorCode { + + NO_ERROR (0x0), + PROTOCOL_ERROR (0x1), + INTERNAL_ERROR (0x2), + FLOW_CONTROL_ERROR (0x3), + SETTINGS_TIMEOUT (0x4), + STREAM_CLOSED (0x5), + FRAME_SIZE_ERROR (0x6), + REFUSED_STREAM (0x7), + CANCEL (0x8), + COMPRESSION_ERROR (0x9), + CONNECT_ERROR (0xa), + ENHANCE_YOUR_CALM (0xb), + INADEQUATE_SECURITY (0xc), + HTTP_1_1_REQUIRED (0xd); + + int value; + + HTTP2ErrorCode(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + public static HTTP2ErrorCode getEnum(int value) { + HTTP2ErrorCode result = HTTP2ErrorCode.NO_ERROR; + + for (HTTP2ErrorCode e : HTTP2ErrorCode.values()) { + if (e.getValue() == value) + result = e; + } + return result; + } + +} diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Exception.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Exception.java new file mode 100644 index 0000000..be20189 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Exception.java @@ -0,0 +1,30 @@ +package robaho.net.httpserver.http2; + +public class HTTP2Exception extends Exception { + + private final HTTP2ErrorCode errorCode; + + public HTTP2Exception(HTTP2ErrorCode errorCode) { + this(errorCode,""); + } + + public HTTP2Exception(HTTP2ErrorCode errorCod, String message) { + super(message); + errorCode = errorCod; + } + + public HTTP2Exception(HTTP2ErrorCode errorCod, Throwable cause) { + super(cause); + errorCode = errorCod; + } + + public HTTP2Exception(String message,Exception cause) { + super(message,cause); + errorCode = HTTP2ErrorCode.INTERNAL_ERROR; + } + public HTTP2ErrorCode getErrorCode() + { + return errorCode; + } + +} diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java new file mode 100644 index 0000000..d8d5b84 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java @@ -0,0 +1,452 @@ +package robaho.net.httpserver.http2; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.System.Logger; +import java.lang.System.Logger.Level; +import java.net.InetSocketAddress; +import java.util.EnumSet; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.LockSupport; +import java.util.concurrent.locks.ReentrantLock; + +import com.sun.net.httpserver.Headers; + +import robaho.net.httpserver.NoSyncBufferedOutputStream; +import robaho.net.httpserver.http2.hpack.HPackContext; +import robaho.net.httpserver.http2.frame.BaseFrame; +import robaho.net.httpserver.http2.frame.DataFrame; +import robaho.net.httpserver.http2.frame.FrameFlag; +import robaho.net.httpserver.http2.frame.FrameHeader; +import robaho.net.httpserver.http2.frame.FrameType; +import robaho.net.httpserver.http2.frame.ResetStreamFrame; +import robaho.net.httpserver.http2.frame.SettingIdentifier; +import robaho.net.httpserver.http2.frame.WindowUpdateFrame; + +public class HTTP2Stream { + + private final int streamId; + + private final AtomicLong sendWindow = new AtomicLong(65535); + + private final HTTP2Connection connection; + private final Logger logger; + private final OutputStream outputStream; + private final Pipe pipe; + private final HTTP2Connection.StreamHandler handler; + private final Headers requestHeaders; + private final Headers responseHeaders = new Headers(); + private volatile boolean headersSent = false; + + private volatile Thread thread; + private volatile boolean streamOpen = true; + private volatile boolean halfClosed = false; + + private long dataInSize = 0; + + public HTTP2Stream(int streamId, HTTP2Connection connection, Headers requestHeaders, HTTP2Connection.StreamHandler handler) throws IOException { + this.streamId = streamId; + this.connection = connection; + this.logger = connection.logger; + this.requestHeaders = requestHeaders; + this.handler = handler; + this.pipe = new Pipe(); + this.outputStream = new NoSyncBufferedOutputStream(new Http2OutputStream(streamId)); + var setting = connection.getRemoteSettings().get(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE); + if(setting!=null) + sendWindow.addAndGet((int)(setting.value-65535)); + } + + public OutputStream getOutputStream() { + return outputStream; + } + + public Headers getRequestHeaders() { + return requestHeaders; + } + + public Headers getResponseHeaders() { + return responseHeaders; + } + + @Override + public String toString() { + return connection.toString()+", stream "+streamId; + } + + public boolean isOpen() { + return streamOpen; + } + public boolean isHalfClosed() { + return halfClosed; + } + + public void close() { + streamOpen = false; + halfClosed = true; + + if(connection.http2Streams.remove(streamId)==null) { + return; + } + + try { + pipe.close(); + outputStream.close(); + + connection.lock(); + try { + // if stream was already closed, then ResetFrame was received, so do not send end of stream + FrameHeader header = new FrameHeader(0, FrameType.DATA, EnumSet.of(FrameFlag.END_STREAM), streamId); + header.writeTo(connection.outputStream); + connection.outputStream.flush(); + } finally { + connection.unlock(); + } + if(thread!=null) + thread.interrupt(); + } catch (IOException e) { + if(!connection.isClosed()) { + logger.log(Level.WARNING,"IOException closing http2 stream",e); + } + } finally { + } + } + + public void processFrame(BaseFrame frame) throws HTTP2Exception, IOException { + + switch (frame.getHeader().getType()) { + case HEADERS: + case CONTINUATION: + if(halfClosed) { + throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED); + } + performRequest(frame.getHeader().getFlags().contains(FrameFlag.END_STREAM)); + break; + case DATA: + DataFrame dataFrame = (DataFrame) frame; + logger.log(Level.TRACE,"received data frame, length "+dataFrame.body.length+" on stream "+streamId); + if(halfClosed) { + throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED); + } + if(!streamOpen) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + pipe.getOutputStream().write(dataFrame.body); + logger.log(Level.TRACE,"wrote data frame to pipe, length "+dataFrame.body.length+" on stream "+streamId); + dataInSize += dataFrame.body.length; + if (dataFrame.getHeader().getFlags().contains(FrameFlag.END_STREAM)) { + if(requestHeaders.containsKey("Content-length")) { + if(dataInSize!=Long.parseLong(requestHeaders.getFirst("Content-length"))) { + connection.sendResetStream(HTTP2ErrorCode.PROTOCOL_ERROR, streamId); + close(); + break; + } + } + pipe.closeOutput(); + halfClosed = true; + } + break; + case PRIORITY: + if(streamOpen) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + break; + case RST_STREAM: + ResetStreamFrame resetFrame = (ResetStreamFrame) frame; + logger.log(Level.DEBUG,"received reset stream "+resetFrame.errorCode+", on stream "+streamId); + close(); + break; + case WINDOW_UPDATE: + int windowSizeIncrement = ((WindowUpdateFrame)frame).getWindowSizeIncrement(); + if(sendWindow.addAndGet(windowSizeIncrement)> 2147483647) { + connection.sendResetStream(HTTP2ErrorCode.FLOW_CONTROL_ERROR, streamId); + close(); + } + logger.log(Level.DEBUG,"received window update "+windowSizeIncrement+", new size "+sendWindow.get()+", on stream "+streamId); + break; + default: + break; + } + } + + private void performRequest(boolean halfClosed) throws IOException { + connection.httpConnection.requestCount.incrementAndGet(); + + InputStream in = halfClosed ? InputStream.nullInputStream() : pipe.getInputStream(); + + if(halfClosed) { + this.halfClosed = true; + pipe.closeOutput(); + } + + handler.getExecutor().execute(() -> { + thread = Thread.currentThread(); + try { + handler.handleStream(this,in,outputStream); + } catch (IOException ex) { + close(); + } + }); + } + public void writeResponseHeaders() throws IOException { + if(headersSent) return; + connection.lock(); + try { + if (headersSent) { + return; + } + HPackContext.writeHeaderFrame(responseHeaders, connection.outputStream, streamId); + connection.outputStream.flush(); + } finally { + headersSent = true; + connection.unlock(); + } + } + public InetSocketAddress getLocalAddress() { + return connection.getLocalAddress(); + } + + public InetSocketAddress getRemoteAddress() { + return connection.getRemoteAddress(); + } + + class Http2OutputStream extends OutputStream { + private final int streamId; + private final OutputStream outputStream = connection.outputStream; + private final int max_frame_size; + private boolean closed; + + public Http2OutputStream(int streamId) { + this.streamId = streamId; + var setting = connection.getRemoteSettings().get(SettingIdentifier.SETTINGS_MAX_FRAME_SIZE); + max_frame_size = setting!=null ? (int)setting.value : 16384; + } + + @Override + public void write(int b) throws IOException { + write(new byte[]{(byte) b}); + } + + @Override + public void write(byte[] b) throws IOException { + write(b, 0, b.length); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + // test outside of lock so other streams can progress + while(sendWindow.get()<=0 && !connection.isClosed()) { + logger.log(Level.TRACE,() -> "sending stream window exhausted, pausing on stream "+streamId); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + } + connection.lock(); + try { + if (!headersSent) { + writeResponseHeaders(); + } + while(len>0) { + int _len = Math.min(Math.min(len,max_frame_size),(int)Math.min(connection.sendWindow.get(),sendWindow.get())); + if(_len<=0) { + logger.log(Level.TRACE,() -> "sending connection window exhausted, pausing on stream "+streamId); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + if(connection.isClosed()) { + throw new IOException("connection closed"); + } + continue; + } + FrameHeader header = new FrameHeader(_len, FrameType.DATA, EnumSet.noneOf(FrameFlag.class), streamId); + logger.log(Level.TRACE,() -> "sending data frame length "+_len+" on stream "+streamId); + header.writeTo(outputStream); + outputStream.write(b, off, _len); + off+=_len; + len-=_len; + connection.sendWindow.addAndGet(-_len); + sendWindow.addAndGet(-_len); + } + } finally { + connection.unlock(); + } + } + @Override + public void flush() throws IOException { + connection.lock(); + try { + outputStream.flush(); + } finally { + connection.unlock(); + } + } + @Override + public void close() throws IOException { + if(closed) return; + connection.lock(); + try { + if(connection.isClosed()) { + if(!headersSent) { + logger.log(Level.WARNING,"stream connection is closed and headers not sent on stream "+streamId); + } + return; + } + if (!headersSent) { + writeResponseHeaders(); + } + outputStream.flush(); + } finally { + closed=true; + connection.unlock(); + HTTP2Stream.this.close(); + } + } + } + + private static class Pipe { + private final CustomPipedInputStream inputStream; + private final CustomPipedOutputStream outputStream; + + public Pipe() { + this.inputStream = new CustomPipedInputStream(); + this.outputStream = new CustomPipedOutputStream(this.inputStream); + } + + public InputStream getInputStream() { + return inputStream; + } + + public OutputStream getOutputStream() { + return outputStream; + } + + public void close() throws IOException { + inputStream.close(); + outputStream.close(); + } + + public void closeOutput() throws IOException { + outputStream.close(); + } + } + + private static class CustomPipedInputStream extends InputStream { + private final byte[] buffer = new byte[1024]; + private int readPos = 0; + private int writePos = 0; + private boolean closed = false; + private final Lock lock = new ReentrantLock(); + private final Condition notEmpty = lock.newCondition(); + private final Condition notFull = lock.newCondition(); + + @Override + public int read() throws IOException { + lock.lock(); + try { + while (readPos == writePos && !closed) { + try { + notEmpty.await(); + } catch (InterruptedException e) { + throw new IOException("Interrupted while waiting for data", e); + } + } + if (closed && readPos == writePos) { + return -1; + } + int result = buffer[readPos++] & 0xFF; + if (readPos == buffer.length) { + readPos = 0; + } + notFull.signal(); + return result; + } finally { + lock.unlock(); + } + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { + int bytesRead = 0; + while (bytesRead < len) { + int byteRead = read(); + if (byteRead == -1) { + return bytesRead == 0 ? -1 : bytesRead; + } + b[off + bytesRead++] = (byte) byteRead; + } + return bytesRead; + } + + @Override + public void close() throws IOException { + lock.lock(); + try { + closed = true; + notEmpty.signalAll(); + notFull.signalAll(); + } finally { + lock.unlock(); + } + } + } + + private static class CustomPipedOutputStream extends OutputStream { + private final CustomPipedInputStream inputStream; + private boolean closed = false; + + public CustomPipedOutputStream(CustomPipedInputStream inputStream) { + this.inputStream = inputStream; + } + + @Override + public void write(int b) throws IOException { + write(new byte[]{(byte) b}); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + inputStream.lock.lock(); + try { + while (len > 0) { + while ((inputStream.writePos == inputStream.readPos - 1 || + (inputStream.writePos == inputStream.buffer.length - 1 && inputStream.readPos == 0)) + && !closed) { + try { + inputStream.notFull.await(); + } catch (InterruptedException e) { + throw new IOException("Interrupted while waiting for buffer space", e); + } + } + if (closed) { + throw new IOException("Stream closed"); + } + int space = inputStream.readPos <= inputStream.writePos ? + inputStream.buffer.length - inputStream.writePos : + inputStream.readPos - inputStream.writePos - 1; + int bytesToWrite = Math.min(len, space); + System.arraycopy(b, off, inputStream.buffer, inputStream.writePos, bytesToWrite); + inputStream.writePos += bytesToWrite; + if (inputStream.writePos == inputStream.buffer.length) { + inputStream.writePos = 0; + } + off += bytesToWrite; + len -= bytesToWrite; + inputStream.notEmpty.signal(); + } + } finally { + inputStream.lock.unlock(); + } + } + + @Override + public void close() throws IOException { + inputStream.lock.lock(); + try { + closed = true; + inputStream.close(); + } finally { + inputStream.lock.unlock(); + } + } + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/Utils.java b/src/main/java/robaho/net/httpserver/http2/Utils.java new file mode 100644 index 0000000..278e83a --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/Utils.java @@ -0,0 +1,82 @@ +package robaho.net.httpserver.http2; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.List; + +public class Utils { + + public static int convertToInt(byte[] bytes, int off) throws Exception { + return convertToInt(bytes, off, 4); + } + + public static int convertToInt(byte[] bytes, int off, int length) throws Exception { + int result = 0; + if (off + length <= bytes.length) { + + int counter = 0; + for (int i = off + length-1 ; i >=off ; i--) { + result |= (bytes[i] & 0x00ff) << (8 * counter); + counter++; + } + + } else { + throw new Exception(String.format("cannot read %s bytes from offset, goes beyond array boundaries", length)); + } + return result; + + } + + public static long convertToLong(byte[] bytes, int off, int length) throws Exception { + long result = 0; + if (off + length <= bytes.length) { + int counter = 0; + for (int i = off + length-1 ; i >=off ; i--) { + result |= Long.valueOf(bytes[i] & 0xff) << (8 * counter); + counter++; + } + } else { + throw new Exception(String.format("cannot read %s bytes from offset, goes beyond array boundries", length)); + } + return result; + + } + + public static void convertToBinary(byte[] buffer, int pos, int input) { + convertToBinary(buffer, pos, input, 4); + } + + public static void convertToBinary(byte[] buffer, int pos, int input, int length) { + for (int i = pos; i < pos + length; i++) { + buffer[i] = (byte) ((input >> (8 * i)) & 255); + } + } + + public static void writeBinary(OutputStream os,int input) throws IOException { + writeBinary(os, input, 4); + } + + public static void writeBinary(OutputStream os,int input,int length) throws IOException { + for (int i = length-1; i>=0; i--) { + os.write((byte) ((input >> (8 * i)) & 0xFF)); + } + } + + public static byte[] combineByteArrays(List blocks) { + if(blocks.size()==1) return blocks.get(0); + + int totalLength = 0; + for (byte[] block : blocks) { + totalLength += block.length; + } + + byte[] combined = new byte[totalLength]; + int offset = 0; + for (byte[] block : blocks) { + System.arraycopy(block, 0, combined, offset, block.length); + offset += block.length; + } + + return combined; + } +} \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/http2/frame/BaseFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/BaseFrame.java new file mode 100644 index 0000000..217afb0 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/BaseFrame.java @@ -0,0 +1,25 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; + +public abstract class BaseFrame { + + private FrameHeader header; + + public BaseFrame(FrameHeader header) { + this.header = header; + } + + public FrameHeader getHeader() + { + return header; + } + + public void setHeader(FrameHeader header) + { + this.header = header; + } + + public abstract void writeTo(OutputStream os) throws IOException; +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/ContinuationFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/ContinuationFrame.java new file mode 100644 index 0000000..877f967 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/ContinuationFrame.java @@ -0,0 +1,28 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; + +import robaho.net.httpserver.http2.HTTP2Exception; + +public class ContinuationFrame extends BaseFrame { + private final byte[] body; + + public ContinuationFrame(FrameHeader header,byte[] body) { + super(header); + this.body = body; + } + + @Override + public void writeTo(OutputStream os) throws IOException { + getHeader().writeTo(os); + } + + public static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws HTTP2Exception { + return new ContinuationFrame(frameHeader,body); + } + public byte[] getHeaderBlock() { + return body; + } + +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/DataFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/DataFrame.java new file mode 100644 index 0000000..ab3fd0c --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/DataFrame.java @@ -0,0 +1,35 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Arrays; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; + +public class DataFrame extends BaseFrame { + public final byte[] body; + public DataFrame(FrameHeader header,byte[] body) { + super(header); + this.body = body; + } + @Override + public void writeTo(OutputStream outputStream) throws IOException { + outputStream.write(body); + } + public static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws HTTP2Exception { + int index = 0; + int padding = 0; + if(frameHeader.getFlags().contains(FrameFlag.PADDED)) { + padding = (body[index] & 0xFF)+1; + if(padding > body.length) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"padding exceeds frame size"); + } + } + if(padding>0) { + return new DataFrame(frameHeader, Arrays.copyOfRange(body,index,body.length-padding)); + } else { + return new DataFrame(frameHeader, body); + } + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/FrameFlag.java b/src/main/java/robaho/net/httpserver/http2/frame/FrameFlag.java new file mode 100644 index 0000000..2eef22d --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/FrameFlag.java @@ -0,0 +1,81 @@ +package robaho.net.httpserver.http2.frame; + +import java.util.EnumSet; + +import robaho.net.httpserver.http2.HTTP2Exception; + +/** + * An enumeration to define all the Flags that can be attached to a frame + */ +public enum FrameFlag { + + END_STREAM((byte)0x1), + ACK((byte)0x1), + END_HEADERS((byte)0x4), + PADDED((byte)0x8), + PRIORITY((byte)0x20); + + + byte value; + + FrameFlag(byte value) { + this.value = value; + } + + public byte getValue() { + return value; + } + + public static EnumSet getEnumSet(byte value, FrameType type) throws HTTP2Exception { + + // Empty EnumSet + EnumSet result = EnumSet.noneOf(FrameFlag.class); + + // Check if the first bit is set + if((value & 1) == 1) + { + // for SETTING and PING frames the first bit indicates whether the frame is ACK + if(type == FrameType.SETTINGS || type == FrameType.PING) + { + result.add(FrameFlag.ACK); + } + else + { + result.add(FrameFlag.END_STREAM); + } + + // reset the first bit + value = (byte)(value ^ 1); + } + + // For each flag in FrameFlag + for (FrameFlag flag : FrameFlag.values()) { + // Check whether the flag bit is set + if ((value & flag.value) != 0) { + result.add(flag); + + // reset the flag bit + value = (byte)(value ^ flag.value); + } + } + + if(value != 0) { + // Unknown bit flag is set, according to the spec we should ignore it + // throw new HTTP2Exception(HTTP2ErrorCode.CONNECT_ERROR, "Unknown bit flag is set: " + value); + } + + return result; + } + + public static byte getValue(EnumSet flags) { + + byte result = 0; + + for (FrameFlag flag : flags) { + result = (byte) (result | flag.getValue()); + } + + return result; + } + +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/FrameHeader.java b/src/main/java/robaho/net/httpserver/http2/frame/FrameHeader.java new file mode 100644 index 0000000..4745b92 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/FrameHeader.java @@ -0,0 +1,108 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.EnumSet; + +import robaho.net.httpserver.http2.Utils; + +/** + * Create a frame header object + */ +public class FrameHeader { + + private final int length; + private final FrameType type; + private final EnumSet flags; + private final int streamIdentifier; + + /** + * 24-bit unsigned integer value that specifies length of the + * frame + */ + public int getLength() { + return length; + } + + /** + * defined as an enum FrameType, it identifies the type of the + * frame + */ + public FrameType getType() { + return type; + } + + /** + * defined as an EnumSet<FrameFlag>, it identifies flags associated with a + * particular frame + */ + public EnumSet getFlags() { + return flags; + } + + /** + * 31-bit unsigned integer uniquely identifies a frame + */ + public int getStreamIdentifier() { + return streamIdentifier; + } + + public FrameHeader(int length, FrameType type, EnumSet flags, int streamIdentifier) { + this.length = length; + this.type = type; + this.flags = flags; + this.streamIdentifier = streamIdentifier; + } + + @Override + public String toString() { + return "FrameHeader{" + + "length=" + length + + ", type=" + type + + ", flags=" + flags + + ", streamIdentifier=" + streamIdentifier + + '}'; + } + + /** + * Parse the 9 bytes frame header to determine length, type, flags and the stream identifier + * @param tmpBuffer 9 bytes frame header + * @return + * @throws Exception + */ + // TODO validate the frame size, frame number, and number of frames in session based on the SETTINGS frame + public static FrameHeader Parse(byte[] tmpBuffer) throws Exception { + FrameHeader frameHeader = null; + + FrameType type = null; + EnumSet flag = null; + int streamIdentifier = 0; + int length = 0; + int readIndex = 0; + + length = Utils.convertToInt(tmpBuffer, readIndex, 3); + readIndex += 3; + + type = FrameType.getEnum(tmpBuffer[readIndex]); + readIndex++; + + flag = FrameFlag.getEnumSet(tmpBuffer[readIndex], type); + readIndex++; + + streamIdentifier = Utils.convertToInt(tmpBuffer, readIndex); + readIndex += 4; + + streamIdentifier = streamIdentifier & 0x7FFFFFFF; + + frameHeader = new FrameHeader(length, type, flag, streamIdentifier); + + return frameHeader; + } + + public void writeTo(OutputStream os) throws IOException { + Utils.writeBinary(os,this.length,3); + os.write(this.getType().value & 0xFF); + os.write(FrameFlag.getValue(this.getFlags()) & 0xFF); + Utils.writeBinary(os,this.streamIdentifier); + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/FrameSerializer.java b/src/main/java/robaho/net/httpserver/http2/frame/FrameSerializer.java new file mode 100644 index 0000000..17c8a6d --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/FrameSerializer.java @@ -0,0 +1,64 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.InputStream; + +import robaho.net.httpserver.http2.HTTP2Connection; +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; + +public class FrameSerializer { + + public static BaseFrame deserialize(InputStream inputStream) throws Exception { + + BaseFrame baseFrame = null; + byte[] tmpBuffer = new byte[9]; + + HTTP2Connection.readFully(inputStream, tmpBuffer); + FrameHeader frameHeader = FrameHeader.Parse(tmpBuffer); + + byte[] body = new byte[frameHeader.getLength()]; + HTTP2Connection.readFully(inputStream, body); + + if(frameHeader.getLength() > 16384) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + + switch (frameHeader.getType()) { + case HEADERS: + baseFrame = HeadersFrame.parse(body, frameHeader); + break; + case CONTINUATION: + baseFrame = ContinuationFrame.parse(body,frameHeader); + break; + case DATA: + baseFrame = DataFrame.parse(body,frameHeader); + break; + case GOAWAY: + baseFrame = GoawayFrame.parse(body,frameHeader); + break; + case PING: + baseFrame = PingFrame.parse(body,frameHeader); + break; + case PRIORITY: + baseFrame = PriorityFrame.parse(body,frameHeader); + break; + case PUSH_PROMISE: + baseFrame = PushPromiseFrame.parse(body,frameHeader); + break; + case RST_STREAM: + baseFrame = ResetStreamFrame.parse(body,frameHeader); + break; + case SETTINGS: + baseFrame = SettingsFrame.parse(body, frameHeader); + break; + case WINDOW_UPDATE: + baseFrame = WindowUpdateFrame.parse(body, frameHeader); + break; + default: + baseFrame = NotImplementedFrame.parse(body,frameHeader); + break; + } + + return baseFrame; + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/FrameType.java b/src/main/java/robaho/net/httpserver/http2/frame/FrameType.java new file mode 100644 index 0000000..6337a8d --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/FrameType.java @@ -0,0 +1,34 @@ +package robaho.net.httpserver.http2.frame; + +/** + * [rfc7540: Section 11.2] A enumeration of all the Frame types introduced in + * HTTP2 This specification defines a number of frame types, each identified by + * a unique 8-bit type code. Each frame type serves a distinct purpose in the + * establishment and management either of the connection as a whole or of + * individual streams. + */ +public enum FrameType { + + DATA((byte) 0x0), HEADERS((byte) 0x1), PRIORITY((byte) 0x2), RST_STREAM((byte) 0x3), SETTINGS( + (byte) 0x4), PUSH_PROMISE((byte) 0x5), PING((byte) 0x6), GOAWAY( + (byte) 0x7), WINDOW_UPDATE((byte) 0x8), CONTINUATION((byte) 0x9), NOT_IMPLEMENTED((byte) 0xA); + + byte value; + + FrameType(byte value) { + this.value = value; + } + + public byte getValue() { + return value; + } + + public static FrameType getEnum(int value) { + for (FrameType e : FrameType.values()) { + if (e.getValue() == value) + return e; + } + System.out.println("FrameType.getEnum: value not found: " + value); + return FrameType.NOT_IMPLEMENTED; + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/GoawayFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/GoawayFrame.java new file mode 100644 index 0000000..b63fcd6 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/GoawayFrame.java @@ -0,0 +1,37 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.Utils; + +public class GoawayFrame extends BaseFrame { + public final HTTP2ErrorCode errorCode; + + public GoawayFrame(FrameHeader header,HTTP2ErrorCode errorCode) { + super(header); + this.errorCode = errorCode; + } + + @Override + public void writeTo(OutputStream os) throws IOException { + getHeader().writeTo(os); + } + + public static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws HTTP2Exception { + if(frameHeader.getStreamIdentifier()!=0) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + if(body.length<8) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + try { + var errorCode = Utils.convertToInt(body, 4); + return new GoawayFrame(frameHeader,HTTP2ErrorCode.getEnum(errorCode)); + } catch (Exception e) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/HeadersFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/HeadersFrame.java new file mode 100644 index 0000000..b7ffad0 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/HeadersFrame.java @@ -0,0 +1,172 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.Arrays; +import java.util.EnumSet; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.Utils; + +/** + * [rfc7540 Section 6.2] The HEADERS frame (type=0x1) is used to open a stream + * (Section 5.1), and additionally carries a header block fragment. HEADERS + * frames can be sent on a stream in the "idle", "reserved (local)", "open", or + * "half-closed (remote)" state. + */ +public class HeadersFrame extends BaseFrame { + + private int padLength; + private boolean isExclusive; + private long dependentStreamId; + private int weight; + private byte[] headerBlock; + private byte[] padding; + + public HeadersFrame() { + this(new FrameHeader(0, FrameType.HEADERS, EnumSet.noneOf(FrameFlag.class), 0)); + } + + public HeadersFrame(FrameHeader header) { + super(header); + } + + /** + * An 8-bit field containing the length of the frame padding in units of + * octets. This field is only present if the PADDED flag is set. + */ + public int getPadLength() { + return padLength; + } + + public void setPadLength(int padLength) { + this.padLength = padLength; + } + + /** + * A single-bit flag indicating that the stream dependency is exclusive (see + * Section 5.3). This field is only present if the PRIORITY flag is set. + */ + public boolean getIsExclusive() { + return isExclusive; + } + + public void setIsExclusive(boolean isExclusive) { + this.isExclusive = isExclusive; + } + + /** + * A 31-bit stream identifier for the stream that this stream depends on + * (see Section 5.3). This field is only present if the PRIORITY flag is + * set. + */ + public long getDependentStream() { + return dependentStreamId; + } + + public void setDependentStream(long streamIdentifier) { + this.dependentStreamId = streamIdentifier; + } + + /** + * An unsigned 8-bit integer representing a priority weight for the stream + * (see Section 5.3). Add one to the value to obtain a weight between 1 and + * 256. This field is only present if the PRIORITY flag is set. + */ + public int getWeight() { + return weight; + } + + public void setWeight(int weight) { + this.weight = weight; + } + + /** + * A header block fragment (Section 4.3). + */ + public byte[] getHeaderBlock() { + return headerBlock; + } + + public void setHeaderBlock(byte[] headerBlock) { + this.headerBlock = headerBlock; + } + + /** + * Padding octets. + */ + public byte[] getPadding() { + return padding; + } + + public void setPadding(byte[] padding) { + this.padding = padding; + } + + /** + * + * @param frameBody + * payload of the HeadersFrame after consuming the FrameHeader + * @param header + * FrameHeader + * @return HeadersFrame object + * @throws HTTP2Exception + * @throws Exception + */ + public static HeadersFrame parse(byte[] frameBody, FrameHeader header) throws HTTP2Exception, Exception { + + if(frameBody == null) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + + if(header.getLength() != frameBody.length) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + + int paramIndex = 0; + + HeadersFrame headersFrame = new HeadersFrame(header); + + //check for PADDED flag and if set then store padding length + if (header.getFlags().contains(FrameFlag.PADDED)) { + int padLength = Utils.convertToInt(frameBody, paramIndex, 1); + if(padLength >= header.getLength()) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + headersFrame.setPadLength(padLength); + paramIndex += 1; + } + + //check for PRIORITY flag and if set then store the Exclusive bit (isExclusive) and store streamID + if (header.getFlags().contains(FrameFlag.PRIORITY)) { + + var streamId = Utils.convertToInt(frameBody, paramIndex, 4); + paramIndex += 4; + + if ((streamId & 0x80000000L) == 0x80000000L) { + headersFrame.setIsExclusive(true); + } + headersFrame.setDependentStream(streamId & 0x7FFFFFFFL); + if (headersFrame.dependentStreamId == header.getStreamIdentifier()) { + // cannot depend on itself + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + + headersFrame.setWeight( (frameBody[paramIndex] & 0xFF) +1 ); + paramIndex += 1; + } + + headersFrame.setHeaderBlock(Arrays.copyOfRange(frameBody, paramIndex, (header.getLength() - headersFrame.getPadLength()))); + + return headersFrame; + } + + @Override + public void writeTo(OutputStream os) throws IOException { + FrameHeader header = new FrameHeader(getHeaderBlock().length, FrameType.HEADERS, EnumSet.of(FrameFlag.END_HEADERS), getHeader().getStreamIdentifier()); + header.writeTo(os); + os.write(getHeaderBlock()); + os.flush(); + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/NotImplementedFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/NotImplementedFrame.java new file mode 100644 index 0000000..ee64e7f --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/NotImplementedFrame.java @@ -0,0 +1,21 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; + +class NotImplementedFrame extends BaseFrame { + private byte[] body; + public NotImplementedFrame(FrameHeader header,byte[] body) { + super(header); + this.body = body; + } + + @Override + public void writeTo(OutputStream os) throws IOException { + getHeader().writeTo(os); + } + + static BaseFrame parse(byte[] body, FrameHeader frameHeader) { + return new NotImplementedFrame(frameHeader,body); + } +} \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/http2/frame/PingFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/PingFrame.java new file mode 100644 index 0000000..bbbc8c0 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/PingFrame.java @@ -0,0 +1,31 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; + +public class PingFrame extends BaseFrame { + public final byte[] body; + + public PingFrame(FrameHeader header, byte[] body) { + super(header); + this.body = body; + } + + @Override + public void writeTo(OutputStream os) throws IOException { + getHeader().writeTo(os); + } + + public static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws HTTP2Exception { + if (frameHeader.getStreamIdentifier() != 0) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + if(body.length!=8) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + return new PingFrame(frameHeader, body); + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/PriorityFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/PriorityFrame.java new file mode 100644 index 0000000..1fead75 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/PriorityFrame.java @@ -0,0 +1,39 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.Utils; + +public class PriorityFrame extends BaseFrame { + public int streamDependency; + public int weight; + public boolean exclusive; + + public PriorityFrame(FrameHeader header) { + super(header); + } + + @Override + public void writeTo(OutputStream os) throws IOException { + getHeader().writeTo(os); + } + + static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws Exception { + var frame = new PriorityFrame(frameHeader); + if(body.length != 5) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + var tmp = Utils.convertToInt(body, 0, 4); + frame.exclusive = (tmp & 0x80000000) != 0; + frame.streamDependency = tmp & 0x7FFFFFFF; + if(frame.streamDependency == frameHeader.getStreamIdentifier()) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + frame.weight = (body[4] & 0xFF) + 1; + return frame; + } + +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/PushPromiseFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/PushPromiseFrame.java new file mode 100644 index 0000000..2e6b38b --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/PushPromiseFrame.java @@ -0,0 +1,20 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; + +public class PushPromiseFrame extends BaseFrame { + + public PushPromiseFrame(FrameHeader header) { + super(header); + } + + @Override + public void writeTo(OutputStream os) throws IOException { + getHeader().writeTo(os); + } + + static BaseFrame parse(byte[] body, FrameHeader frameHeader) { + return new PushPromiseFrame(frameHeader); + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/ResetStreamFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/ResetStreamFrame.java new file mode 100644 index 0000000..1c7652e --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/ResetStreamFrame.java @@ -0,0 +1,32 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.Utils; +import static robaho.net.httpserver.http2.Utils.convertToInt; + +public class ResetStreamFrame extends BaseFrame { + final public HTTP2ErrorCode errorCode; + + public ResetStreamFrame(FrameHeader header,HTTP2ErrorCode errorCode) { + super(header); + this.errorCode = errorCode; + } + + @Override + public void writeTo(OutputStream os) throws IOException { + getHeader().writeTo(os); + Utils.writeBinary(os,errorCode.getValue(),4); + os.flush(); + } + + static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws HTTP2Exception, Exception { + if(body.length != 4) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + return new ResetStreamFrame(frameHeader,HTTP2ErrorCode.getEnum(convertToInt(body, 0))); + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/SettingIdentifier.java b/src/main/java/robaho/net/httpserver/http2/frame/SettingIdentifier.java new file mode 100644 index 0000000..a8a47ef --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/SettingIdentifier.java @@ -0,0 +1,52 @@ +package robaho.net.httpserver.http2.frame; + +public enum SettingIdentifier { + SETTINGS_HEADER_TABLE_SIZE(0x1), + SETTINGS_ENABLE_PUSH(0x2), + SETTINGS_MAX_CONCURRENT_STREAMS(0x3), + SETTINGS_INITIAL_WINDOW_SIZE(0x4), + SETTINGS_MAX_FRAME_SIZE(0x5), + SETTINGS_MAX_HEADER_LIST_SIZE(0x6), + SETTINGS_NONE(0x0); // Not part of RFC // RFC + + int value; + + SettingIdentifier(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + public static SettingIdentifier getEnum(int value) { + SettingIdentifier result = SettingIdentifier.SETTINGS_NONE; + + for (SettingIdentifier e : SettingIdentifier.values()) { + if (e.getValue() == value) + result = e; + } + return result; + } + + public boolean validateValue(long value) { + switch (this) { + case SETTINGS_HEADER_TABLE_SIZE: + return true; + case SETTINGS_INITIAL_WINDOW_SIZE: + // hackish, but need to check in the application, since a different error must be thrown + return true; + case SETTINGS_MAX_FRAME_SIZE: + return value >= 16384 && value <= 16777215; + case SETTINGS_MAX_HEADER_LIST_SIZE: + return value >= 0; + case SETTINGS_ENABLE_PUSH: + return value == 0 || value == 1; + case SETTINGS_MAX_CONCURRENT_STREAMS: + return value >= 0 && value <= 0x7FFFFFFF; + default: + return false; + } + } + +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/SettingParameter.java b/src/main/java/robaho/net/httpserver/http2/frame/SettingParameter.java new file mode 100644 index 0000000..19fb414 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/SettingParameter.java @@ -0,0 +1,50 @@ +package robaho.net.httpserver.http2.frame; +import java.io.IOException; +import java.io.OutputStream; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.Utils; + +public class SettingParameter { + + static final int PARAMETER_SIZE = 6; + + public SettingIdentifier identifier; + public long value; + + public SettingParameter() { + } + + public SettingParameter(SettingIdentifier identifier, long value) { + this.identifier = identifier; + this.value = value; + } + + public static SettingParameter parse(byte[] param) throws HTTP2Exception + { + try { + if(param.length != 6) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + SettingParameter result = new SettingParameter(); + result.identifier = SettingIdentifier.getEnum(Utils.convertToInt(param,0, 2)); + if(result.identifier == SettingIdentifier.SETTINGS_NONE) { + return null; + } + result.value = Utils.convertToLong(param, 2, 4); + if(!result.identifier.validateValue(result.value)) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"Invalid value for setting "+result.identifier+" "+result.value); + } + return result; + } catch (HTTP2Exception ex) { + throw ex; + } catch (Exception ex) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,ex); + } + } + public void writeTo(OutputStream os) throws IOException { + Utils.writeBinary(os,identifier.getValue(), 2); + Utils.writeBinary(os,(int)value, 4); + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/SettingsFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/SettingsFrame.java new file mode 100644 index 0000000..87b3ab6 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/SettingsFrame.java @@ -0,0 +1,72 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; + +public class SettingsFrame extends BaseFrame { + + ArrayList params = new ArrayList<>(); + + /** + * SettingsFrame Constructor which calls the parameterized constructor + */ + public SettingsFrame() { + this(new FrameHeader(0, FrameType.SETTINGS, EnumSet.of(FrameFlag.ACK) , 0)); + } + + public SettingsFrame(FrameHeader header) { + super(header); + } + + public ArrayList getSettingParameters() { + return params; + } + + public static SettingsFrame parse(byte[] frameBody, FrameHeader header) throws HTTP2Exception { + if (header.getStreamIdentifier() != 0) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); + } + + if(header.getFlags().contains(FrameFlag.ACK)) { + if(header.getLength() != 0) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + } + + if(frameBody.length % 6 != 0) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + + SettingsFrame result = new SettingsFrame(header); + int paramIndex = 0; + + while (paramIndex < frameBody.length) { + var param = SettingParameter.parse(Arrays.copyOfRange(frameBody, paramIndex, paramIndex + 6)); + if(param!=null) { + result.params.add(param); + } + paramIndex += 6; + } + return result; + } + + @Override + public void writeTo(OutputStream os) throws IOException { + + int settingBodySize = params.size() * SettingParameter.PARAMETER_SIZE; + + FrameHeader header = new FrameHeader(settingBodySize,FrameType.SETTINGS,getHeader().getFlags(),getHeader().getStreamIdentifier()); + header.writeTo(os); + + for (int i = 0; i < params.size(); i++) { + params.get(i).writeTo(os); + } + } + +} diff --git a/src/main/java/robaho/net/httpserver/http2/frame/WindowUpdateFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/WindowUpdateFrame.java new file mode 100644 index 0000000..16c422c --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/WindowUpdateFrame.java @@ -0,0 +1,45 @@ +package robaho.net.httpserver.http2.frame; + +import java.io.IOException; +import java.io.OutputStream; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.Utils; + +public class WindowUpdateFrame extends BaseFrame { + + private int windowSizeIncrement; + + public WindowUpdateFrame(FrameHeader header) { + super(header); + } + + public int getWindowSizeIncrement() + { + return windowSizeIncrement; + } + + public static WindowUpdateFrame parse(byte[] frameBody, FrameHeader header) throws HTTP2Exception { + + if(frameBody.length!=4) { + throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); + } + + WindowUpdateFrame frame = new WindowUpdateFrame(header); + try { + frame.windowSizeIncrement = Utils.convertToInt(frameBody, 0); + } catch (Exception e) { + throw new HTTP2Exception(HTTP2ErrorCode.INTERNAL_ERROR); + } + if(frame.windowSizeIncrement==0) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"window size increment == 0"); + } + return frame; + } + + @Override + public void writeTo(OutputStream os) throws IOException { + getHeader().writeTo(os); + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java b/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java new file mode 100644 index 0000000..2a7360e --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java @@ -0,0 +1,351 @@ +package robaho.net.httpserver.http2.hpack; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.EnumSet; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; + +import java.util.List; + +import com.sun.net.httpserver.Headers; + +import robaho.net.httpserver.http2.frame.FrameFlag; +import robaho.net.httpserver.http2.frame.FrameHeader; +import robaho.net.httpserver.http2.frame.FrameType; +import robaho.net.httpserver.http2.Utils; + +public class HPackContext { + private final List dynamicTable = new ArrayList(1024); + + public HPackContext() { + } + + public HTTP2HeaderField getHeaderField(int index) { + if (index > 0 && index <= 61) { + return RFC7541Parser.getHeaderField(index); + } else { + return dynamicTable.get(index - 62); + } + } + + public void addHeaderField(HTTP2HeaderField field) { + dynamicTable.add(0,field); + } + + public List decodeFieldSegments(byte[] buffer) throws HTTP2Exception { + List headers = new ArrayList<>(); + int index = 0; + + try { + + while (index < buffer.length) { + HTTP2HeaderField headerField = new HTTP2HeaderField(); + if ((buffer[index] & 0x80) != 0) { + index = decodeIndexedHeaderField(buffer, index, headerField); + } else if ((buffer[index] & 0x40) != 0) { + // Literal Header Field with Incremental Indexing + index = decodeFieldWithIncrementalIndexing(buffer, index, headerField); + } else if ((buffer[index] & 0xF0) == 0) { + // Literal Header Field without Indexing + index = decodeLiteralFieldWithoutIndexing(buffer, index, headerField); + } else if ((buffer[index] & 0xF0) == 0x10) { + // Literal Header Field never Indexed + index = decodeLiteralFieldNeverIndexed(buffer, index, headerField); + } else if((buffer[index] & 0xE0) == 0x20) { + if(!headers.isEmpty()) { + throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR, "Dynamic table size update must occur at beginning of block"); + } + index = decodeDynamicTableSizeUpdate(buffer,index); + continue; + } else { + throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR, "Invalid header field representation " + buffer[index]); + } + headers.add(headerField); + } + } catch (HTTP2Exception e) { + throw e; + } catch (Exception e) { + throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR, e); + } + + return headers; + } + + private int decodeIndexedHeaderField(byte[] buffer, int index, HTTP2HeaderField headerField) throws HTTP2Exception { + var pair = decodeUnsignedInteger(buffer, index, 7); + int headerIndex = pair.value; + index = pair.index; + + HTTP2HeaderField field = getHeaderField(headerIndex); + if (field == null) { + throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR, "Invalid header index " + headerIndex + ", dynamic: "+dynamicTable); + } + + headerField.setName(field.name); + headerField.setValue(field.value); + + return index; + } + + private int decodeFieldWithIncrementalIndexing(byte[] buffer, int index, HTTP2HeaderField headerField) throws HTTP2Exception { + var pair = decodeUnsignedInteger(buffer, index, 6); + int headerIndex = pair.value; + index = pair.index; + + index = decodeFieldName(buffer, index, headerIndex, headerField); + index = decodeFieldValue(buffer, index, headerField); + + dynamicTable.add(0,headerField); + + return index; + } + private int decodeLiteralFieldWithoutIndexing(byte[] buffer, int index, HTTP2HeaderField headerField) throws HTTP2Exception { + var pair = decodeUnsignedInteger(buffer, index, 4); + int headerIndex = pair.value; + index = pair.index; + + index = decodeFieldName(buffer, index, headerIndex, headerField); + index = decodeFieldValue(buffer, index, headerField); + return index; + } + private int decodeDynamicTableSizeUpdate(byte[] buffer, int index) throws HTTP2Exception { + var pair = decodeUnsignedInteger(buffer, index, 5); + int size = pair.value; + index = pair.index; + + System.out.println("updating dynamic table size to "+size); + + if (size > 4096) { // Assuming 4096 is the maximum size for the dynamic table + throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR, "Dynamic table size update too large: " + size); + } + + while (dynamicTable.size() > size) { + dynamicTable.removeLast(); + } + + return index; + } + + private int decodeFieldName(byte[] buffer, int index, int headerIndex, HTTP2HeaderField headerField) throws HTTP2Exception { + if (headerIndex == 0) { + boolean huffmanCode = (buffer[index] & 0x80) != 0; + int length = buffer[index] & 0x7F; + index++; + byte[] valueBytes = Arrays.copyOfRange(buffer, index, index + length); + index += length; + String value; + if (huffmanCode) { + value = Huffman.decode(valueBytes); + } else { + value = new String(valueBytes); + } + if(!value.equals(value.toLowerCase())) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "header field name is not lowercase " + value); + } + headerField.setName(value); + } else { + var field = getHeaderField(headerIndex); + if (field == null) { + throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR, "Invalid header index " + headerIndex); + } + headerField.setName(field.name); + } + return index; + } + + private int decodeFieldValue(byte[] buffer, int index, HTTP2HeaderField headerField) throws HTTP2Exception { + // Check if Huffman coding is used + boolean huffmanCode = (buffer[index] & 0x80) != 0; + var pair = decodeUnsignedInteger(buffer, index, 7); + index = pair.index; + int length = pair.value; + + byte[] valueBytes = Arrays.copyOfRange(buffer, index, index + length); + String value; + if (huffmanCode) { + value = Huffman.decode(valueBytes); + } else { + value = new String(valueBytes); + } + headerField.setValue(value); + + return index + length; + } + + private static record IndexValuePair(int index, int value) { + } + + private static IndexValuePair decodeUnsignedInteger(byte[] buffer, int index, int prefixBits) { + int value = buffer[index] & ((1 << prefixBits) - 1); + index++; + if (value < ((1 << prefixBits) - 1)) { + return new IndexValuePair(index, value); + } + + int m = 0; + int b; + do { + b = buffer[index] & 0xFF; + value += (b & 0x7F) << m; + m += 7; + index++; + } while ((b & 0x80) != 0); + + return new IndexValuePair(index, value); + } + + private int decodeLiteralFieldNeverIndexed(byte[] buffer, int index, HTTP2HeaderField headerField) throws HTTP2Exception { + var pair = decodeUnsignedInteger(buffer, index, 4); + int headerIndex = pair.value; + index = pair.index; + + index = decodeFieldName(buffer, index, headerIndex, headerField); + index = decodeFieldValue(buffer, index, headerField); + return index; + } + + public static void writeHeaderFrame(Headers headers, OutputStream outputStream, int streamId) throws IOException { + byte[] buffer = encodeHeadersFrame(headers); + FrameHeader header = new FrameHeader(buffer.length, FrameType.HEADERS, EnumSet.of(FrameFlag.END_HEADERS), streamId); + header.writeTo(outputStream); + outputStream.write(buffer); + + // System.out.println("HPACK.writeHeaderFrame: wrote header frame, length: " + buffer.length + ", streamId: " + streamId); + } + + private static byte[] encodeHeadersFrame(Headers headers) { + List fields = new ArrayList(); + for (String name : headers.keySet()) { + for (String value : headers.get(name)) { + byte[] header = encodeHeader(name.toLowerCase(), value); + if(name.startsWith(":")) { + fields.add(0, header); + } else { + fields.add(header); + } + } + } + return Utils.combineByteArrays(fields); + } + + private static byte[] encodeHeader(String name, String value) { + byte[] nameBytes = name.getBytes(); + byte[] valueBytes = value.getBytes(); + byte[] buffer = new byte[1]; + buffer[0]=0x00; // Literal Header Field without Indexing + + // Encode header name + byte[] header = encodeString(nameBytes); + buffer = Arrays.copyOf(buffer, buffer.length + header.length); + System.arraycopy(header, 0, buffer, buffer.length - header.length, header.length); + + // Encode header value + header = encodeString(valueBytes); + buffer = Arrays.copyOf(buffer, buffer.length + header.length); + System.arraycopy(header, 0, buffer, buffer.length - header.length, header.length); + + return buffer; + } + + private static byte[] encodeString(byte[] value) { + byte[] buffer = new byte[0]; + if (value.length < 128) { + buffer = Arrays.copyOf(buffer, buffer.length + 1); + buffer[buffer.length - 1] = (byte) value.length; + } else { + buffer = Arrays.copyOf(buffer, buffer.length + 1); + buffer[buffer.length - 1] = (byte) (value.length | 0x80); + buffer = Arrays.copyOf(buffer, buffer.length + 1); + buffer[buffer.length - 1] = (byte) (value.length >> 7); + } + buffer = Arrays.copyOf(buffer, buffer.length + value.length); + System.arraycopy(value, 0, buffer, buffer.length - value.length, value.length); + return buffer; + } + +} + +class RFC7541Parser { + + private static final HTTP2HeaderField[] STATIC_HEADER_TABLE = new HTTP2HeaderField[62]; + + static { + STATIC_HEADER_TABLE[1] = new HTTP2HeaderField(":authority", null); + STATIC_HEADER_TABLE[2] = new HTTP2HeaderField(":method", "GET"); + STATIC_HEADER_TABLE[3] = new HTTP2HeaderField(":method", "POST"); + STATIC_HEADER_TABLE[4] = new HTTP2HeaderField(":path", "/"); + STATIC_HEADER_TABLE[5] = new HTTP2HeaderField(":path", "/index.html"); + STATIC_HEADER_TABLE[6] = new HTTP2HeaderField(":scheme", "http"); + STATIC_HEADER_TABLE[7] = new HTTP2HeaderField(":scheme", "https"); + STATIC_HEADER_TABLE[8] = new HTTP2HeaderField(":status", "200"); + STATIC_HEADER_TABLE[9] = new HTTP2HeaderField(":status", "204"); + STATIC_HEADER_TABLE[10] = new HTTP2HeaderField(":status", "206"); + STATIC_HEADER_TABLE[11] = new HTTP2HeaderField(":status", "304"); + STATIC_HEADER_TABLE[12] = new HTTP2HeaderField(":status", "400"); + STATIC_HEADER_TABLE[13] = new HTTP2HeaderField(":status", "404"); + STATIC_HEADER_TABLE[14] = new HTTP2HeaderField(":status", "500"); + STATIC_HEADER_TABLE[15] = new HTTP2HeaderField("accept-charset", null); + STATIC_HEADER_TABLE[16] = new HTTP2HeaderField("accept-encoding", "gzip, deflate"); + STATIC_HEADER_TABLE[17] = new HTTP2HeaderField("accept-language", null); + STATIC_HEADER_TABLE[18] = new HTTP2HeaderField("accept-ranges", null); + STATIC_HEADER_TABLE[19] = new HTTP2HeaderField("accept", null); + STATIC_HEADER_TABLE[20] = new HTTP2HeaderField("access-control-allow-origin", null); + STATIC_HEADER_TABLE[21] = new HTTP2HeaderField("age", null); + STATIC_HEADER_TABLE[22] = new HTTP2HeaderField("allow", null); + STATIC_HEADER_TABLE[23] = new HTTP2HeaderField("authorization", null); + STATIC_HEADER_TABLE[24] = new HTTP2HeaderField("cache-control", null); + STATIC_HEADER_TABLE[25] = new HTTP2HeaderField("content-disposition", null); + STATIC_HEADER_TABLE[26] = new HTTP2HeaderField("content-encoding", null); + STATIC_HEADER_TABLE[27] = new HTTP2HeaderField("content-language", null); + STATIC_HEADER_TABLE[28] = new HTTP2HeaderField("content-length", null); + STATIC_HEADER_TABLE[29] = new HTTP2HeaderField("content-location", null); + STATIC_HEADER_TABLE[30] = new HTTP2HeaderField("content-range", null); + STATIC_HEADER_TABLE[31] = new HTTP2HeaderField("content-type", null); + STATIC_HEADER_TABLE[32] = new HTTP2HeaderField("cookie", null); + STATIC_HEADER_TABLE[33] = new HTTP2HeaderField("date", null); + STATIC_HEADER_TABLE[34] = new HTTP2HeaderField("etag", null); + STATIC_HEADER_TABLE[35] = new HTTP2HeaderField("expect", null); + STATIC_HEADER_TABLE[36] = new HTTP2HeaderField("expires", null); + STATIC_HEADER_TABLE[37] = new HTTP2HeaderField("from", null); + STATIC_HEADER_TABLE[38] = new HTTP2HeaderField("host", null); + STATIC_HEADER_TABLE[39] = new HTTP2HeaderField("if-match", null); + STATIC_HEADER_TABLE[40] = new HTTP2HeaderField("if-modified-since", null); + STATIC_HEADER_TABLE[41] = new HTTP2HeaderField("if-none-match", null); + STATIC_HEADER_TABLE[42] = new HTTP2HeaderField("if-range", null); + STATIC_HEADER_TABLE[43] = new HTTP2HeaderField("if-unmodified-since", null); + STATIC_HEADER_TABLE[44] = new HTTP2HeaderField("last-modified", null); + STATIC_HEADER_TABLE[45] = new HTTP2HeaderField("link", null); + STATIC_HEADER_TABLE[46] = new HTTP2HeaderField("location", null); + STATIC_HEADER_TABLE[47] = new HTTP2HeaderField("max-forwards", null); + STATIC_HEADER_TABLE[48] = new HTTP2HeaderField("proxy-authenticate", null); + STATIC_HEADER_TABLE[49] = new HTTP2HeaderField("proxy-authorization", null); + STATIC_HEADER_TABLE[50] = new HTTP2HeaderField("range", null); + STATIC_HEADER_TABLE[51] = new HTTP2HeaderField("referer", null); + STATIC_HEADER_TABLE[52] = new HTTP2HeaderField("refresh", null); + STATIC_HEADER_TABLE[53] = new HTTP2HeaderField("retry-after", null); + STATIC_HEADER_TABLE[54] = new HTTP2HeaderField("server", null); + STATIC_HEADER_TABLE[55] = new HTTP2HeaderField("set-cookie", null); + STATIC_HEADER_TABLE[56] = new HTTP2HeaderField("strict-transport-security", null); + STATIC_HEADER_TABLE[57] = new HTTP2HeaderField("transfer-encoding", null); + STATIC_HEADER_TABLE[58] = new HTTP2HeaderField("user-agent", null); + STATIC_HEADER_TABLE[59] = new HTTP2HeaderField("vary", null); + STATIC_HEADER_TABLE[60] = new HTTP2HeaderField("via", null); + STATIC_HEADER_TABLE[61] = new HTTP2HeaderField("www-authenticate", null); + } + + public static HTTP2HeaderField getHeaderField(int index) { + if (index < 1 || index >= STATIC_HEADER_TABLE.length) { + return null; + } + return STATIC_HEADER_TABLE[index]; + } + + public static String getHeaderFieldName(int index) { + var field = getHeaderField(index); + return field == null ? null : field.getName(); + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/HTTP2HeaderField.java b/src/main/java/robaho/net/httpserver/http2/hpack/HTTP2HeaderField.java new file mode 100644 index 0000000..1d3cc7d --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/hpack/HTTP2HeaderField.java @@ -0,0 +1,41 @@ +package robaho.net.httpserver.http2.hpack; + +public class HTTP2HeaderField { + + public String name; + public String value; + + public HTTP2HeaderField() { + } + + public HTTP2HeaderField(String name, String value) { + this.name = name; + this.value = value; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public String toString() { + return name + ": " + value; + } + + public boolean isPseudoHeader() { + return name.startsWith(":"); + } + +} diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/HeaderFields.java b/src/main/java/robaho/net/httpserver/http2/hpack/HeaderFields.java new file mode 100644 index 0000000..92c6c09 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/hpack/HeaderFields.java @@ -0,0 +1,81 @@ +package robaho.net.httpserver.http2.hpack; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; + +/** + * container for emitted HTTP2HeaderField from field block decoding. Many of the rules of valid + * headers require multi-field inspection, so aggregating and validating at the end is an easier + * design + */ +public class HeaderFields implements Iterable { + private static final Set prohibitedHeaderFields = Set.of("connection"); + private static final Set requiredHeaderFields = Set.of(":path",":method",":scheme"); + private static final Set pseudoHeadersIn = Set.of(":authority", ":method", ":path", ":scheme"); + + private final List fields = new ArrayList(); + private final Map pseudoHeaders = new HashMap(); + + private boolean hasNonPseudoHeader = false; + + /** + * add a HTTP2HeaderField to the collection performing any per field validation + */ + public void addHeaderField(HTTP2HeaderField field) throws HTTP2Exception{ + if(field.isPseudoHeader() && !pseudoHeadersIn.contains(field.getName())) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "invalid pseudo header " + field.getName()); + } + if(prohibitedHeaderFields.contains(field.getName())) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"prohibited header field "+field.getName()); + } + if(field.getName().equals("te") && !field.getValue().equals("trailers")) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"prohibited header field "+field.getName()); + } + if(!field.isPseudoHeader()) { + hasNonPseudoHeader = true; + } + if(field.isPseudoHeader() && hasNonPseudoHeader) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"Pseudo-header fields must appear before regular header fields"); + } + if(field.isPseudoHeader()) { + if(pseudoHeaders.put(field.getName(),field)!=null && requiredHeaderFields.contains(field.getName())) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"invalid duplicate header "+field.getName()); + } + } + fields.add(field); + } + private static boolean isEmpty(String s) { + return s == null || s.trim().equals(""); + } + public void addAll(List httpFields) throws HTTP2Exception { + for(HTTP2HeaderField field : httpFields) { + addHeaderField(field); + } + } + @Override + public Iterator iterator() { + return fields.iterator(); + } + public void clear() { + fields.clear(); + hasNonPseudoHeader = false; + } + /** + * perform the multi-field validation of the collection of header fields + */ + public void validate() throws HTTP2Exception { + for(var fieldName : requiredHeaderFields) { + var ph = pseudoHeaders.get(fieldName); + if(ph==null || isEmpty(ph.getValue())) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"missing required header field "+fieldName); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java b/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java new file mode 100644 index 0000000..a9c2343 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java @@ -0,0 +1,85 @@ +package robaho.net.httpserver.http2.hpack; + +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URISyntaxException; +import java.util.HashMap; + +import robaho.net.httpserver.http2.HTTP2ErrorCode; +import robaho.net.httpserver.http2.HTTP2Exception; + +public class Huffman { + static HashMap huffmanCodes = null; + + public static HashMap getHuffmanCodes() + throws FileNotFoundException, IOException, URISyntaxException { + if (huffmanCodes == null) { + huffmanCodes = new HashMap<>(); + + ClassLoader classloader = Thread.currentThread().getContextClassLoader(); + InputStream is = classloader.getResourceAsStream("huffman_codes_rfc7541.txt"); + + try (BufferedReader br = new BufferedReader(new InputStreamReader(is))) { + String line; + String code; + int value = 0; + while ((line = br.readLine()) != null) { + code = line.substring(11, line.indexOf(' ', 11)); + code = code.replace("|", ""); + huffmanCodes.put(code, value); + value++; + } + } + } + + return huffmanCodes; + } + + public static String decode(byte[] value) throws HTTP2Exception { + StringBuilder result = new StringBuilder(); + + HashMap codes; + try { + codes = getHuffmanCodes(); + } catch (IOException ex) { + throw new HTTP2Exception("Error reading huffman codes", ex); + } catch (URISyntaxException ex) { + throw new HTTP2Exception("Error reading huffman codes", ex); + } + + StringBuilder code = new StringBuilder(); + + for (int i = 0; i < value.length; i++) { + int unsignedByte = value[i] & 0xff; + for (int j = 0; j < 8; j++) { + if ((unsignedByte & 0x00000080) != 0) { + code.append("1"); + } else { + code.append("0"); + } + + unsignedByte = unsignedByte << 1; + + Integer intValue = codes.get(code.toString()); + if(intValue!=null) { + if(intValue==256) { + throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR,"decoded contains EOS "+code); + } + result.append((char)(intValue & 0xFF)); + code.setLength(0); + } + } + } + + // Check for EOS (End of Stream) condition + if (code.length() > 7 || code.indexOf("0")>=0) { + throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR,"decoded has incorrect padding "+code); + } + + return result.toString(); + } + +} diff --git a/src/main/resources/huffman_codes_rfc7541.txt b/src/main/resources/huffman_codes_rfc7541.txt new file mode 100644 index 0000000..f4091a0 --- /dev/null +++ b/src/main/resources/huffman_codes_rfc7541.txt @@ -0,0 +1,257 @@ + ( 0) |11111111|11000 1ff8 [13] + ( 1) |11111111|11111111|1011000 7fffd8 [23] + ( 2) |11111111|11111111|11111110|0010 fffffe2 [28] + ( 3) |11111111|11111111|11111110|0011 fffffe3 [28] + ( 4) |11111111|11111111|11111110|0100 fffffe4 [28] + ( 5) |11111111|11111111|11111110|0101 fffffe5 [28] + ( 6) |11111111|11111111|11111110|0110 fffffe6 [28] + ( 7) |11111111|11111111|11111110|0111 fffffe7 [28] + ( 8) |11111111|11111111|11111110|1000 fffffe8 [28] + ( 9) |11111111|11111111|11101010 ffffea [24] + ( 10) |11111111|11111111|11111111|111100 3ffffffc [30] + ( 11) |11111111|11111111|11111110|1001 fffffe9 [28] + ( 12) |11111111|11111111|11111110|1010 fffffea [28] + ( 13) |11111111|11111111|11111111|111101 3ffffffd [30] + ( 14) |11111111|11111111|11111110|1011 fffffeb [28] + ( 15) |11111111|11111111|11111110|1100 fffffec [28] + ( 16) |11111111|11111111|11111110|1101 fffffed [28] + ( 17) |11111111|11111111|11111110|1110 fffffee [28] + ( 18) |11111111|11111111|11111110|1111 fffffef [28] + ( 19) |11111111|11111111|11111111|0000 ffffff0 [28] + ( 20) |11111111|11111111|11111111|0001 ffffff1 [28] + ( 21) |11111111|11111111|11111111|0010 ffffff2 [28] + ( 22) |11111111|11111111|11111111|111110 3ffffffe [30] + ( 23) |11111111|11111111|11111111|0011 ffffff3 [28] + ( 24) |11111111|11111111|11111111|0100 ffffff4 [28] + ( 25) |11111111|11111111|11111111|0101 ffffff5 [28] + ( 26) |11111111|11111111|11111111|0110 ffffff6 [28] + ( 27) |11111111|11111111|11111111|0111 ffffff7 [28] + ( 28) |11111111|11111111|11111111|1000 ffffff8 [28] + ( 29) |11111111|11111111|11111111|1001 ffffff9 [28] + ( 30) |11111111|11111111|11111111|1010 ffffffa [28] + ( 31) |11111111|11111111|11111111|1011 ffffffb [28] +' ' ( 32) |010100 14 [ 6] +'!' ( 33) |11111110|00 3f8 [10] +'"' ( 34) |11111110|01 3f9 [10] +'#' ( 35) |11111111|1010 ffa [12] +'$' ( 36) |11111111|11001 1ff9 [13] +'%' ( 37) |010101 15 [ 6] +'&' ( 38) |11111000 f8 [ 8] +''' ( 39) |11111111|010 7fa [11] +'(' ( 40) |11111110|10 3fa [10] +')' ( 41) |11111110|11 3fb [10] +'*' ( 42) |11111001 f9 [ 8] +'+' ( 43) |11111111|011 7fb [11] +',' ( 44) |11111010 fa [ 8] +'-' ( 45) |010110 16 [ 6] +'.' ( 46) |010111 17 [ 6] +'/' ( 47) |011000 18 [ 6] +'0' ( 48) |00000 0 [ 5] +'1' ( 49) |00001 1 [ 5] +'2' ( 50) |00010 2 [ 5] +'3' ( 51) |011001 19 [ 6] +'4' ( 52) |011010 1a [ 6] +'5' ( 53) |011011 1b [ 6] +'6' ( 54) |011100 1c [ 6] +'7' ( 55) |011101 1d [ 6] +'8' ( 56) |011110 1e [ 6] +'9' ( 57) |011111 1f [ 6] +':' ( 58) |1011100 5c [ 7] +';' ( 59) |11111011 fb [ 8] +'<' ( 60) |11111111|1111100 7ffc [15] +'=' ( 61) |100000 20 [ 6] +'>' ( 62) |11111111|1011 ffb [12] +'?' ( 63) |11111111|00 3fc [10] +'@' ( 64) |11111111|11010 1ffa [13] +'A' ( 65) |100001 21 [ 6] +'B' ( 66) |1011101 5d [ 7] +'C' ( 67) |1011110 5e [ 7] +'D' ( 68) |1011111 5f [ 7] +'E' ( 69) |1100000 60 [ 7] +'F' ( 70) |1100001 61 [ 7] +'G' ( 71) |1100010 62 [ 7] +'H' ( 72) |1100011 63 [ 7] +'I' ( 73) |1100100 64 [ 7] +'J' ( 74) |1100101 65 [ 7] +'K' ( 75) |1100110 66 [ 7] +'L' ( 76) |1100111 67 [ 7] +'M' ( 77) |1101000 68 [ 7] +'N' ( 78) |1101001 69 [ 7] +'O' ( 79) |1101010 6a [ 7] +'P' ( 80) |1101011 6b [ 7] +'Q' ( 81) |1101100 6c [ 7] +'R' ( 82) |1101101 6d [ 7] +'S' ( 83) |1101110 6e [ 7] +'T' ( 84) |1101111 6f [ 7] +'U' ( 85) |1110000 70 [ 7] +'V' ( 86) |1110001 71 [ 7] +'W' ( 87) |1110010 72 [ 7] +'X' ( 88) |11111100 fc [ 8] +'Y' ( 89) |1110011 73 [ 7] +'Z' ( 90) |11111101 fd [ 8] +'[' ( 91) |11111111|11011 1ffb [13] +'\' ( 92) |11111111|11111110|000 7fff0 [19] +']' ( 93) |11111111|11100 1ffc [13] +'^' ( 94) |11111111|111100 3ffc [14] +'_' ( 95) |100010 22 [ 6] +'`' ( 96) |11111111|1111101 7ffd [15] +'a' ( 97) |00011 3 [ 5] +'b' ( 98) |100011 23 [ 6] +'c' ( 99) |00100 4 [ 5] +'d' (100) |100100 24 [ 6] +'e' (101) |00101 5 [ 5] +'f' (102) |100101 25 [ 6] +'g' (103) |100110 26 [ 6] +'h' (104) |100111 27 [ 6] +'i' (105) |00110 6 [ 5] +'j' (106) |1110100 74 [ 7] +'k' (107) |1110101 75 [ 7] +'l' (108) |101000 28 [ 6] +'m' (109) |101001 29 [ 6] +'n' (110) |101010 2a [ 6] +'o' (111) |00111 7 [ 5] +'p' (112) |101011 2b [ 6] +'q' (113) |1110110 76 [ 7] +'r' (114) |101100 2c [ 6] +'s' (115) |01000 8 [ 5] +'t' (116) |01001 9 [ 5] +'u' (117) |101101 2d [ 6] +'v' (118) |1110111 77 [ 7] +'w' (119) |1111000 78 [ 7] +'x' (120) |1111001 79 [ 7] +'y' (121) |1111010 7a [ 7] +'z' (122) |1111011 7b [ 7] +'{' (123) |11111111|1111110 7ffe [15] +'|' (124) |11111111|100 7fc [11] +'}' (125) |11111111|111101 3ffd [14] +'~' (126) |11111111|11101 1ffd [13] + (127) |11111111|11111111|11111111|1100 ffffffc [28] + (128) |11111111|11111110|0110 fffe6 [20] + (129) |11111111|11111111|010010 3fffd2 [22] + (130) |11111111|11111110|0111 fffe7 [20] + (131) |11111111|11111110|1000 fffe8 [20] + (132) |11111111|11111111|010011 3fffd3 [22] + (133) |11111111|11111111|010100 3fffd4 [22] + (134) |11111111|11111111|010101 3fffd5 [22] + (135) |11111111|11111111|1011001 7fffd9 [23] + (136) |11111111|11111111|010110 3fffd6 [22] + (137) |11111111|11111111|1011010 7fffda [23] + (138) |11111111|11111111|1011011 7fffdb [23] + (139) |11111111|11111111|1011100 7fffdc [23] + (140) |11111111|11111111|1011101 7fffdd [23] + (141) |11111111|11111111|1011110 7fffde [23] + (142) |11111111|11111111|11101011 ffffeb [24] + (143) |11111111|11111111|1011111 7fffdf [23] + (144) |11111111|11111111|11101100 ffffec [24] + (145) |11111111|11111111|11101101 ffffed [24] + (146) |11111111|11111111|010111 3fffd7 [22] + (147) |11111111|11111111|1100000 7fffe0 [23] + (148) |11111111|11111111|11101110 ffffee [24] + (149) |11111111|11111111|1100001 7fffe1 [23] + (150) |11111111|11111111|1100010 7fffe2 [23] + (151) |11111111|11111111|1100011 7fffe3 [23] + (152) |11111111|11111111|1100100 7fffe4 [23] + (153) |11111111|11111110|11100 1fffdc [21] + (154) |11111111|11111111|011000 3fffd8 [22] + (155) |11111111|11111111|1100101 7fffe5 [23] + (156) |11111111|11111111|011001 3fffd9 [22] + (157) |11111111|11111111|1100110 7fffe6 [23] + (158) |11111111|11111111|1100111 7fffe7 [23] + (159) |11111111|11111111|11101111 ffffef [24] + (160) |11111111|11111111|011010 3fffda [22] + (161) |11111111|11111110|11101 1fffdd [21] + (162) |11111111|11111110|1001 fffe9 [20] + (163) |11111111|11111111|011011 3fffdb [22] + (164) |11111111|11111111|011100 3fffdc [22] + (165) |11111111|11111111|1101000 7fffe8 [23] + (166) |11111111|11111111|1101001 7fffe9 [23] + (167) |11111111|11111110|11110 1fffde [21] + (168) |11111111|11111111|1101010 7fffea [23] + (169) |11111111|11111111|011101 3fffdd [22] + (170) |11111111|11111111|011110 3fffde [22] + (171) |11111111|11111111|11110000 fffff0 [24] + (172) |11111111|11111110|11111 1fffdf [21] + (173) |11111111|11111111|011111 3fffdf [22] + (174) |11111111|11111111|1101011 7fffeb [23] + (175) |11111111|11111111|1101100 7fffec [23] + (176) |11111111|11111111|00000 1fffe0 [21] + (177) |11111111|11111111|00001 1fffe1 [21] + (178) |11111111|11111111|100000 3fffe0 [22] + (179) |11111111|11111111|00010 1fffe2 [21] + (180) |11111111|11111111|1101101 7fffed [23] + (181) |11111111|11111111|100001 3fffe1 [22] + (182) |11111111|11111111|1101110 7fffee [23] + (183) |11111111|11111111|1101111 7fffef [23] + (184) |11111111|11111110|1010 fffea [20] + (185) |11111111|11111111|100010 3fffe2 [22] + (186) |11111111|11111111|100011 3fffe3 [22] + (187) |11111111|11111111|100100 3fffe4 [22] + (188) |11111111|11111111|1110000 7ffff0 [23] + (189) |11111111|11111111|100101 3fffe5 [22] + (190) |11111111|11111111|100110 3fffe6 [22] + (191) |11111111|11111111|1110001 7ffff1 [23] + (192) |11111111|11111111|11111000|00 3ffffe0 [26] + (193) |11111111|11111111|11111000|01 3ffffe1 [26] + (194) |11111111|11111110|1011 fffeb [20] + (195) |11111111|11111110|001 7fff1 [19] + (196) |11111111|11111111|100111 3fffe7 [22] + (197) |11111111|11111111|1110010 7ffff2 [23] + (198) |11111111|11111111|101000 3fffe8 [22] + (199) |11111111|11111111|11110110|0 1ffffec [25] + (200) |11111111|11111111|11111000|10 3ffffe2 [26] + (201) |11111111|11111111|11111000|11 3ffffe3 [26] + (202) |11111111|11111111|11111001|00 3ffffe4 [26] + (203) |11111111|11111111|11111011|110 7ffffde [27] + (204) |11111111|11111111|11111011|111 7ffffdf [27] + (205) |11111111|11111111|11111001|01 3ffffe5 [26] + (206) |11111111|11111111|11110001 fffff1 [24] + (207) |11111111|11111111|11110110|1 1ffffed [25] + (208) |11111111|11111110|010 7fff2 [19] + (209) |11111111|11111111|00011 1fffe3 [21] + (210) |11111111|11111111|11111001|10 3ffffe6 [26] + (211) |11111111|11111111|11111100|000 7ffffe0 [27] + (212) |11111111|11111111|11111100|001 7ffffe1 [27] + (213) |11111111|11111111|11111001|11 3ffffe7 [26] + (214) |11111111|11111111|11111100|010 7ffffe2 [27] + (215) |11111111|11111111|11110010 fffff2 [24] + (216) |11111111|11111111|00100 1fffe4 [21] + (217) |11111111|11111111|00101 1fffe5 [21] + (218) |11111111|11111111|11111010|00 3ffffe8 [26] + (219) |11111111|11111111|11111010|01 3ffffe9 [26] + (220) |11111111|11111111|11111111|1101 ffffffd [28] + (221) |11111111|11111111|11111100|011 7ffffe3 [27] + (222) |11111111|11111111|11111100|100 7ffffe4 [27] + (223) |11111111|11111111|11111100|101 7ffffe5 [27] + (224) |11111111|11111110|1100 fffec [20] + (225) |11111111|11111111|11110011 fffff3 [24] + (226) |11111111|11111110|1101 fffed [20] + (227) |11111111|11111111|00110 1fffe6 [21] + (228) |11111111|11111111|101001 3fffe9 [22] + (229) |11111111|11111111|00111 1fffe7 [21] + (230) |11111111|11111111|01000 1fffe8 [21] + (231) |11111111|11111111|1110011 7ffff3 [23] + (232) |11111111|11111111|101010 3fffea [22] + (233) |11111111|11111111|101011 3fffeb [22] + (234) |11111111|11111111|11110111|0 1ffffee [25] + (235) |11111111|11111111|11110111|1 1ffffef [25] + (236) |11111111|11111111|11110100 fffff4 [24] + (237) |11111111|11111111|11110101 fffff5 [24] + (238) |11111111|11111111|11111010|10 3ffffea [26] + (239) |11111111|11111111|1110100 7ffff4 [23] + (240) |11111111|11111111|11111010|11 3ffffeb [26] + (241) |11111111|11111111|11111100|110 7ffffe6 [27] + (242) |11111111|11111111|11111011|00 3ffffec [26] + (243) |11111111|11111111|11111011|01 3ffffed [26] + (244) |11111111|11111111|11111100|111 7ffffe7 [27] + (245) |11111111|11111111|11111101|000 7ffffe8 [27] + (246) |11111111|11111111|11111101|001 7ffffe9 [27] + (247) |11111111|11111111|11111101|010 7ffffea [27] + (248) |11111111|11111111|11111101|011 7ffffeb [27] + (249) |11111111|11111111|11111111|1110 ffffffe [28] + (250) |11111111|11111111|11111101|100 7ffffec [27] + (251) |11111111|11111111|11111101|101 7ffffed [27] + (252) |11111111|11111111|11111101|110 7ffffee [27] + (253) |11111111|11111111|11111101|111 7ffffef [27] + (254) |11111111|11111111|11111110|000 7fffff0 [27] + (255) |11111111|11111111|11111011|10 3ffffee [26] +EOS (256) |11111111|11111111|11111111|111111 3fffffff [30] \ No newline at end of file diff --git a/src/test/extras/SimpleFileServer.java b/src/test/extras/SimpleFileServer.java index 7dfc7a1..5459628 100644 --- a/src/test/extras/SimpleFileServer.java +++ b/src/test/extras/SimpleFileServer.java @@ -28,6 +28,7 @@ import com.sun.net.httpserver.*; +import jdk.test.lib.net.SimpleSSLContext; import robaho.net.httpserver.LogFilter; import robaho.net.httpserver.extras.ContentEncoding; import robaho.net.httpserver.extras.QueryParameters; @@ -60,10 +61,19 @@ public static void main(String[] args) throws Exception { String rootDir = args[0]; int port = Integer.parseInt(args[1]); String logfile = args[2]; - HttpServer server = HttpServer.create(new InetSocketAddress(port), 200); + HttpServer server; + if(port==443) { + var httpsServer = HttpsServer.create(new InetSocketAddress(port), 8192); + var ctx = new SimpleSSLContext().get(); + httpsServer.setHttpsConfigurator(new HttpsConfigurator (ctx)); + server = httpsServer; + } else { + server = HttpServer.create(new InetSocketAddress(port), 8192); + } HttpHandler h = new FileServerHandler(rootDir); HttpHandler h1 = new EchoHandler(); HttpHandler h2 = new DevNullHandler(); + HttpHandler h3 = new HelloWorldHandler(); HttpContext c = server.createContext("/files", h); c.getFilters().add(new LogFilter(new File(logfile))); @@ -74,8 +84,11 @@ public static void main(String[] args) throws Exception { HttpContext c2 = server.createContext("/devnull", h2); c2.getFilters().add(new LogFilter(new File(logfile))); + HttpContext c3 = server.createContext("/", h3); + + server.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); - // server.setExecutor(Executors.newCachedThreadPool()); + // server.setExecutor(Executors.newFixedThreadPool(8)); server.start(); } @@ -98,4 +111,15 @@ public void handle(HttpExchange exchange) throws IOException { } } + private static class HelloWorldHandler implements HttpHandler { + private static final byte[] bytes = "Hello World".getBytes(); + public void handle(HttpExchange exchange) throws IOException { + QueryParameters qp = QueryParameters.decode(ContentEncoding.encoding(exchange.getRequestHeaders()), exchange.getRequestURI().getQuery()); + long size = bytes.length; + exchange.sendResponseHeaders(200, size); + OutputStream os = exchange.getResponseBody(); + os.write(bytes); + os.close(); + } + } } diff --git a/src/test/java/FileServerHandler.java b/src/test/java/FileServerHandler.java index bff7844..7abe93e 100644 --- a/src/test/java/FileServerHandler.java +++ b/src/test/java/FileServerHandler.java @@ -26,8 +26,12 @@ import java.util.logging.*; import java.io.*; import java.net.*; +import java.nio.file.Files; +import java.nio.file.Path; import java.security.*; + import javax.net.ssl.*; + import com.sun.net.httpserver.*; /** @@ -57,14 +61,18 @@ public void handle (HttpExchange t) URI uri = t.getRequestURI(); String path = uri.getPath(); - int x = 0; - while (is.read () != -1) x++; + is.readAllBytes(); is.close(); + File f = new File (docroot, path); + Path filepath = Files.isSymbolicLink(f.toPath()) ? Files.readSymbolicLink(f.toPath()) : f.toPath(); + f = filepath.toFile(); + if (!f.exists()) { notfound (t, path); return; } + String fixedrequest = map.getFirst ("XFixed"); String method = t.getRequestMethod(); @@ -109,21 +117,9 @@ public void handle (HttpExchange t) clen = 0; } t.sendResponseHeaders (200, clen); - OutputStream os = t.getResponseBody(); - FileInputStream fis = new FileInputStream (f); - int count = 0; - try { - byte[] buf = new byte [16 * 1024]; - int len; - while ((len=fis.read (buf)) != -1) { - os.write (buf, 0, len); - count += len; - } - } catch (IOException e) { - e.printStackTrace(); + try (OutputStream os = t.getResponseBody(); FileInputStream fis = new FileInputStream (f)) { + fis.transferTo(os); } - fis.close(); - os.close(); } } From eed5701a283ff973be939880da01439356be6c52 Mon Sep 17 00:00:00 2001 From: robert engels Date: Mon, 30 Dec 2024 17:18:51 -0600 Subject: [PATCH 07/78] update readme for Http2 --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2a39598..7d79b94 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ It has basic server-side proxy support using [ProxyHandler](https://github.com/r ProxyHandler also supports tunneling proxies using CONNECT for https. +It supports Http2 [RFC 9113](https://www.rfc-editor.org/rfc/rfc9113.html) + All async functionality has been removed. All synchronized blocks were removed in favor of other Java concurrency concepts. The end result is an implementation that easily integrates with Virtual Threads available in JDK 21 - simply set a virtual thread based ExecutorService. @@ -165,6 +167,10 @@ The counts can be reset using `/__stats?reset`. The `requests/sec` is calculated 1.0.11 ``` -## future work +## enable Http2 + +Use `-Drobaho.net.httpserver.http2.http2OverSSL` to enable Http2 only via SSL connections. + +Use `-Drobaho.net.httpserver.http2.http2OverNonSSL` to enable Http2 on Non-SSL connections (which requires prior knowledge). The Http2 upgrade mechanism was deprecated in RFC 9113 so it is not supported. -There is no http2 support. +See the additional Http2 options in `ServerConfig.java` From 378f657cf9c4c4319acf6e45818c7f574a06f3df Mon Sep 17 00:00:00 2001 From: robert engels Date: Mon, 30 Dec 2024 17:19:55 -0600 Subject: [PATCH 08/78] version 1.0.12 --- README.md | 2 +- build.gradle | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7d79b94..2682b85 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ The counts can be reset using `/__stats?reset`. The `requests/sec` is calculated io.github.robaho httpserver - 1.0.11 + 1.0.12 ``` ## enable Http2 diff --git a/build.gradle b/build.gradle index 45cf3e6..f79b0dc 100644 --- a/build.gradle +++ b/build.gradle @@ -130,10 +130,10 @@ task runSimpleFileServer(type: Test) { main "SimpleFileServer" systemProperties = props args = ['fileserver','8080','fileserver/logfile.txt'] - debugOptions { - enabled = true - suspend = false - } + // debugOptions { + // enabled = true + // suspend = false + // } } } } @@ -153,7 +153,7 @@ publishing { maven(MavenPublication) { groupId = 'io.github.robaho' artifactId = 'httpserver' - version = "1.0.11" + version = "1.0.12" from components.java From 786936d17293fa52a6cd12b22a957eec8e7c5523 Mon Sep 17 00:00:00 2001 From: robert engels Date: Mon, 30 Dec 2024 17:24:09 -0600 Subject: [PATCH 09/78] fix http2 enablement settings in readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2682b85..8f20383 100644 --- a/README.md +++ b/README.md @@ -169,8 +169,8 @@ The counts can be reset using `/__stats?reset`. The `requests/sec` is calculated ``` ## enable Http2 -Use `-Drobaho.net.httpserver.http2.http2OverSSL` to enable Http2 only via SSL connections. +Use `-Drobaho.net.httpserver.http2OverSSL` to enable Http2 only via SSL connections. -Use `-Drobaho.net.httpserver.http2.http2OverNonSSL` to enable Http2 on Non-SSL connections (which requires prior knowledge). The Http2 upgrade mechanism was deprecated in RFC 9113 so it is not supported. +Use `-Drobaho.net.httpserver.http2OverNonSSL` to enable Http2 on Non-SSL connections (which requires prior knowledge). The Http2 upgrade mechanism was deprecated in RFC 9113 so it is not supported. See the additional Http2 options in `ServerConfig.java` From 91d689483ad7d5bbbd38588ad11152a833ea46fc Mon Sep 17 00:00:00 2001 From: robert engels Date: Mon, 30 Dec 2024 19:57:26 -0600 Subject: [PATCH 10/78] update readme for http2 specification tests --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 8f20383..a523171 100644 --- a/README.md +++ b/README.md @@ -174,3 +174,5 @@ Use `-Drobaho.net.httpserver.http2OverSSL` to enable Http2 only via SSL connecti Use `-Drobaho.net.httpserver.http2OverNonSSL` to enable Http2 on Non-SSL connections (which requires prior knowledge). The Http2 upgrade mechanism was deprecated in RFC 9113 so it is not supported. See the additional Http2 options in `ServerConfig.java` + +The http2 implementation passes all specification tests in [h2spec](https://github.com/summerwind/h2spec) From 2edf0a60d9119e190ca0fdf36c71be5287498d29 Mon Sep 17 00:00:00 2001 From: robert engels Date: Mon, 30 Dec 2024 19:58:30 -0600 Subject: [PATCH 11/78] fix http2 enablement properties in readme --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a523171..1541c39 100644 --- a/README.md +++ b/README.md @@ -169,9 +169,11 @@ The counts can be reset using `/__stats?reset`. The `requests/sec` is calculated ``` ## enable Http2 -Use `-Drobaho.net.httpserver.http2OverSSL` to enable Http2 only via SSL connections. +Http2 support is enabled via Java system properties. -Use `-Drobaho.net.httpserver.http2OverNonSSL` to enable Http2 on Non-SSL connections (which requires prior knowledge). The Http2 upgrade mechanism was deprecated in RFC 9113 so it is not supported. +Use `-Drobaho.net.httpserver.http2OverSSL=true` to enable Http2 only via SSL connections. + +Use `-Drobaho.net.httpserver.http2OverNonSSL=true` to enable Http2 on Non-SSL connections (which requires prior knowledge). The Http2 upgrade mechanism was deprecated in RFC 9113 so it is not supported. See the additional Http2 options in `ServerConfig.java` From 8ccbd5f5b77e31e8b7ced6e062501e27c3ca709b Mon Sep 17 00:00:00 2001 From: robert engels Date: Tue, 31 Dec 2024 10:40:52 -0600 Subject: [PATCH 12/78] fix http2 compliance when server returns data for testing endpoint --- README.md | 47 ++++++++++++++++++ build.gradle | 4 +- .../robaho/net/httpserver/ServerConfig.java | 10 ++++ .../net/httpserver/http2/HTTP2Connection.java | 10 +++- .../net/httpserver/http2/HTTP2Stream.java | 49 +++++++++++-------- .../net/httpserver/http2/frame/FrameType.java | 1 - .../http2/frame/SettingParameter.java | 2 + 7 files changed, 99 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 1541c39..0163af7 100644 --- a/README.md +++ b/README.md @@ -178,3 +178,50 @@ Use `-Drobaho.net.httpserver.http2OverNonSSL=true` to enable Http2 on Non-SSL co See the additional Http2 options in `ServerConfig.java` The http2 implementation passes all specification tests in [h2spec](https://github.com/summerwind/h2spec) + +## Http2 performance + +Http2 performance has not yet been optimized, but an unscientific test shows the http2 implementation to have greater than 50% better throughput than the Javalin/Jetty 11 version. + +The Javalin/Jetty project is available [here](https://github.com/robaho/javalin-http2-example) + +TODO: outbound headers are only minimally compressed/indexed. + +
+ performance details + +All tests were run on the same hardware with the same JDK23 version. + +Jetty 11 +``` +starting benchmark... +spawning thread #0: 16 total client(s). 1000000 total requests +Application protocol: h2c +finished in 5.25s, 190298.69 req/s, 6.72MB/s +requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout +status codes: 1000000 2xx, 0 3xx, 0 4xx, 0 5xx +traffic: 35.29MB (37003264) total, 7.63MB (8002384) headers (space savings 90.12%), 10.49MB (11000000) data + min max mean sd +/- sd +time for request: 160us 52.24ms 7.76ms 3.94ms 67.73% +time for connect: 235us 8.82ms 4.73ms 2.68ms 62.50% +time to 1st byte: 11.16ms 33.62ms 20.95ms 9.28ms 50.00% +req/s : 11894.25 12051.63 11957.08 58.94 56.25% +``` + +robaho http2 +``` +starting benchmark... +spawning thread #0: 16 total client(s). 1000000 total requests +Application protocol: h2c +finished in 2.97s, 336884.32 req/s, 14.14MB/s +requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout +status codes: 1000000 2xx, 0 3xx, 0 4xx, 0 5xx +traffic: 41.96MB (44000480) total, 5.72MB (6000000) headers (space savings 76.92%), 10.49MB (11000000) data + min max mean sd +/- sd +time for request: 406us 83.67ms 25.15ms 13.16ms 67.28% +time for connect: 188us 11.70ms 5.99ms 3.69ms 56.25% +time to 1st byte: 14.13ms 31.81ms 22.80ms 6.61ms 43.75% +req/s : 21059.44 21271.63 21141.16 75.01 68.75% +``` + +
\ No newline at end of file diff --git a/build.gradle b/build.gradle index f79b0dc..f869371 100644 --- a/build.gradle +++ b/build.gradle @@ -25,7 +25,9 @@ tasks.withType(Test) { jvmArgs += "--add-opens=jdk.httpserver/com.sun.net.httpserver=ALL-UNNAMED" systemProperty("java.util.logging.config.file","logging.properties") systemProperty("com.sun.net.httpserver.HttpServerProvider","robaho.net.httpserver.DefaultHttpServerProvider") - systemProperty("robaho.net.httpserver.http2MaxConcurrentStreams","5000") + systemProperty("robaho.net.httpserver.http2OverNonSSL","true") + // systemProperty("robaho.net.httpserver.http2MaxConcurrentStreams","5000") + // systemProperty("robaho.net.httpserver.http2DisableFlushDelay","true") // systemProperty("javax.net.debug","ssl:handshake:verbose:keymanager:trustmanager") } diff --git a/src/main/java/robaho/net/httpserver/ServerConfig.java b/src/main/java/robaho/net/httpserver/ServerConfig.java index eff3d9e..b7fd694 100644 --- a/src/main/java/robaho/net/httpserver/ServerConfig.java +++ b/src/main/java/robaho/net/httpserver/ServerConfig.java @@ -80,6 +80,7 @@ public class ServerConfig { private static int http2MaxFrameSize; private static int http2InitialWindowSize; private static int http2MaxConcurrentStreams; + private static boolean http2DisableFlushDelay; static { java.security.AccessController.doPrivileged( @@ -143,6 +144,7 @@ public Void run() { http2InitialWindowSize = Integer.getInteger(pkg + ".http2InitialWindowSize", DEFAULT_HTTP2_INITIAL_WINDOW_SIZE); http2MaxConcurrentStreams = Integer.getInteger(pkg + ".http2MaxConcurrentStreams", DEFAULT_HTTP2_MAX_CONCURRENT_STREAMS); + http2DisableFlushDelay = Boolean.getBoolean(pkg + ".http2DisableFlushDelay"); return null; } @@ -253,4 +255,12 @@ public static int http2InitialWindowSize() { public static int http2MaxConcurrentStreams() { return http2MaxConcurrentStreams; } + /** + * @return true if delaying flush is enabled. disabling the flush delay can improve + * latency at the expense of throughput + */ + public static boolean http2DisableFlushDelay() { + return http2DisableFlushDelay; + } + } diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java index 26107d4..cd4eaff 100644 --- a/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java @@ -71,6 +71,7 @@ public class HTTP2Connection { final AtomicLong sendWindow = new AtomicLong(65535); final AtomicInteger receiveWindow = new AtomicInteger(65535); + final AtomicInteger requestsInProgress = new AtomicInteger(); private int maxConcurrentStreams = -1; private int highNumberStreams = 0; @@ -329,12 +330,18 @@ private void processFrames() throws Exception { } public void updateRemoteSettings(SettingsFrame remoteSettingFrame) throws HTTP2Exception { + logger.log(Level.TRACE,() -> "updating remote settings"); + for (SettingParameter parameter : remoteSettingFrame.getSettingParameters()) { + long oldInitialWindowSize = remoteSettings.getOrDefault(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE, SettingParameter.DEFAULT_INITIAL_WINDOWSIZE).value; if(parameter.identifier == SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE) { if(parameter.value > 2147483647) { throw new HTTP2Exception(HTTP2ErrorCode.FLOW_CONTROL_ERROR,"Invalid value for SETTINGS_INITIAL_WINDOW_SIZE "+parameter.value); } logger.log(Level.DEBUG,() -> "received initial window size of "+parameter.value); + for(var stream : http2Streams.values()) { + stream.sendWindow.addAndGet(parameter.value-oldInitialWindowSize); + } } if(parameter.identifier == SettingIdentifier.SETTINGS_MAX_FRAME_SIZE) { logger.log(Level.DEBUG,() -> "received max frame size "+parameter.value); @@ -344,6 +351,7 @@ public void updateRemoteSettings(SettingsFrame remoteSettingFrame) throws HTTP2E } public void sendSettingsAck() throws IOException { + logger.log(Level.TRACE,() -> "sending Settings Ack"); lock(); try { SettingsFrame frame = new SettingsFrame(); @@ -351,7 +359,7 @@ public void sendSettingsAck() throws IOException { outputStream.flush(); } finally { unlock(); - logger.log(Level.TRACE,() -> "Sent Settings Ack"); + logger.log(Level.TRACE,() -> "sent Settings Ack"); } } public void sendMySettings() throws IOException { diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java index d8d5b84..3b4a68e 100644 --- a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java @@ -17,6 +17,7 @@ import com.sun.net.httpserver.Headers; import robaho.net.httpserver.NoSyncBufferedOutputStream; +import robaho.net.httpserver.ServerConfig; import robaho.net.httpserver.http2.hpack.HPackContext; import robaho.net.httpserver.http2.frame.BaseFrame; import robaho.net.httpserver.http2.frame.DataFrame; @@ -31,7 +32,8 @@ public class HTTP2Stream { private final int streamId; - private final AtomicLong sendWindow = new AtomicLong(65535); + // needs to be accessible for connection to adjust based on SettingsFrame + final AtomicLong sendWindow = new AtomicLong(65535); private final HTTP2Connection connection; private final Logger logger; @@ -96,21 +98,11 @@ public void close() { try { pipe.close(); outputStream.close(); - - connection.lock(); - try { - // if stream was already closed, then ResetFrame was received, so do not send end of stream - FrameHeader header = new FrameHeader(0, FrameType.DATA, EnumSet.of(FrameFlag.END_STREAM), streamId); - header.writeTo(connection.outputStream); - connection.outputStream.flush(); - } finally { - connection.unlock(); - } if(thread!=null) thread.interrupt(); } catch (IOException e) { if(!connection.isClosed()) { - logger.log(Level.WARNING,"IOException closing http2 stream",e); + logger.log(connection.httpConnection.requestCount.get()>0 ? Level.WARNING : Level.DEBUG,"IOException closing http2 stream",e); } } finally { } @@ -175,6 +167,7 @@ public void processFrame(BaseFrame frame) throws HTTP2Exception, IOException { private void performRequest(boolean halfClosed) throws IOException { connection.httpConnection.requestCount.incrementAndGet(); + connection.requestsInProgress.incrementAndGet(); InputStream in = halfClosed ? InputStream.nullInputStream() : pipe.getInputStream(); @@ -200,7 +193,6 @@ public void writeResponseHeaders() throws IOException { return; } HPackContext.writeHeaderFrame(responseHeaders, connection.outputStream, streamId); - connection.outputStream.flush(); } finally { headersSent = true; connection.unlock(); @@ -219,6 +211,7 @@ class Http2OutputStream extends OutputStream { private final OutputStream outputStream = connection.outputStream; private final int max_frame_size; private boolean closed; + private long pauses = 0; public Http2OutputStream(int streamId) { this.streamId = streamId; @@ -240,7 +233,7 @@ public void write(byte[] b) throws IOException { public void write(byte[] b, int off, int len) throws IOException { // test outside of lock so other streams can progress while(sendWindow.get()<=0 && !connection.isClosed()) { - logger.log(Level.TRACE,() -> "sending stream window exhausted, pausing on stream "+streamId); + pauses++; LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); } connection.lock(); @@ -251,12 +244,17 @@ public void write(byte[] b, int off, int len) throws IOException { while(len>0) { int _len = Math.min(Math.min(len,max_frame_size),(int)Math.min(connection.sendWindow.get(),sendWindow.get())); if(_len<=0) { - logger.log(Level.TRACE,() -> "sending connection window exhausted, pausing on stream "+streamId); - LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); - if(connection.isClosed()) { - throw new IOException("connection closed"); + try { + connection.unlock(); + pauses++; + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(16)); + if(connection.isClosed()) { + throw new IOException("connection closed"); + } + continue; + } finally { + connection.lock(); } - continue; } FrameHeader header = new FrameHeader(_len, FrameType.DATA, EnumSet.noneOf(FrameFlag.class), streamId); logger.log(Level.TRACE,() -> "sending data frame length "+_len+" on stream "+streamId); @@ -265,7 +263,9 @@ public void write(byte[] b, int off, int len) throws IOException { off+=_len; len-=_len; connection.sendWindow.addAndGet(-_len); - sendWindow.addAndGet(-_len); + if(sendWindow.addAndGet(-_len)<=0) { + outputStream.flush(); + } } } finally { connection.unlock(); @@ -283,6 +283,8 @@ public void flush() throws IOException { @Override public void close() throws IOException { if(closed) return; + if(pauses>0) + logger.log(Level.TRACE,() -> "sending stream window exhausted "+pauses+" on stream "+streamId); connection.lock(); try { if(connection.isClosed()) { @@ -294,7 +296,11 @@ public void close() throws IOException { if (!headersSent) { writeResponseHeaders(); } - outputStream.flush(); + FrameHeader header = new FrameHeader(0, FrameType.DATA, EnumSet.of(FrameFlag.END_STREAM), streamId); + header.writeTo(connection.outputStream); + if(ServerConfig.http2DisableFlushDelay() || connection.requestsInProgress.decrementAndGet()==0) { + connection.outputStream.flush(); + } } finally { closed=true; connection.unlock(); @@ -303,6 +309,7 @@ public void close() throws IOException { } } + // custom Pipe implementation since JDK version still uses synchronized methods which are not optimal for virtual threads private static class Pipe { private final CustomPipedInputStream inputStream; private final CustomPipedOutputStream outputStream; diff --git a/src/main/java/robaho/net/httpserver/http2/frame/FrameType.java b/src/main/java/robaho/net/httpserver/http2/frame/FrameType.java index 6337a8d..a124665 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/FrameType.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/FrameType.java @@ -28,7 +28,6 @@ public static FrameType getEnum(int value) { if (e.getValue() == value) return e; } - System.out.println("FrameType.getEnum: value not found: " + value); return FrameType.NOT_IMPLEMENTED; } } diff --git a/src/main/java/robaho/net/httpserver/http2/frame/SettingParameter.java b/src/main/java/robaho/net/httpserver/http2/frame/SettingParameter.java index 19fb414..088b9eb 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/SettingParameter.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/SettingParameter.java @@ -13,6 +13,8 @@ public class SettingParameter { public SettingIdentifier identifier; public long value; + public static SettingParameter DEFAULT_INITIAL_WINDOWSIZE = new SettingParameter(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE,65535); + public SettingParameter() { } From 9463497df0a5afd1831ef2758c443d35eb67c27f Mon Sep 17 00:00:00 2001 From: robert engels Date: Tue, 31 Dec 2024 10:41:08 -0600 Subject: [PATCH 13/78] use basic compression for outbound headers --- .../httpserver/http2/hpack/HPackContext.java | 71 ++++++++++++++++--- 1 file changed, 62 insertions(+), 9 deletions(-) diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java b/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java index 2a7360e..e5aec68 100644 --- a/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java +++ b/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java @@ -10,6 +10,7 @@ import robaho.net.httpserver.http2.HTTP2Exception; import java.util.List; +import java.util.Map; import com.sun.net.httpserver.Headers; @@ -117,8 +118,6 @@ private int decodeDynamicTableSizeUpdate(byte[] buffer, int index) throws HTTP2E int size = pair.value; index = pair.index; - System.out.println("updating dynamic table size to "+size); - if (size > 4096) { // Assuming 4096 is the maximum size for the dynamic table throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR, "Dynamic table size update too large: " + size); } @@ -233,18 +232,27 @@ private static byte[] encodeHeadersFrame(Headers headers) { } private static byte[] encodeHeader(String name, String value) { - byte[] nameBytes = name.getBytes(); - byte[] valueBytes = value.getBytes(); + if(":status".equals(name)) { + byte[] result = RFC7541Parser.STATUSES.get(value); + if(result!=null) return result; + } + var index = RFC7541Parser.getIndex(name); + byte[] buffer = new byte[1]; buffer[0]=0x00; // Literal Header Field without Indexing - // Encode header name - byte[] header = encodeString(nameBytes); - buffer = Arrays.copyOf(buffer, buffer.length + header.length); - System.arraycopy(header, 0, buffer, buffer.length - header.length, header.length); + if(index!=null) { + buffer = encodeIndexedField(index,4); + } else { + byte[] nameBytes = name.getBytes(); + byte[] header = encodeString(nameBytes); + buffer = Arrays.copyOf(buffer, buffer.length + header.length); + System.arraycopy(header, 0, buffer, buffer.length - header.length, header.length); + } // Encode header value - header = encodeString(valueBytes); + byte[] valueBytes = value.getBytes(); + byte[] header = encodeString(valueBytes); buffer = Arrays.copyOf(buffer, buffer.length + header.length); System.arraycopy(header, 0, buffer, buffer.length - header.length, header.length); @@ -267,11 +275,44 @@ private static byte[] encodeString(byte[] value) { return buffer; } + private static byte[] encodeIndexedField(int index, int prefixBits) { + byte[] buffer = new byte[1]; + int mask = (1 << prefixBits) - 1; + if (index < mask) { + buffer[0] = (byte) index; + return buffer; + } + buffer[0] = (byte) mask; + index -= mask; + while (index >= 128) { + buffer = Arrays.copyOf(buffer, buffer.length + 1); + buffer[buffer.length - 1] = (byte) ((index & 0x7F) | 0x80); + index >>>= 7; + } + buffer = Arrays.copyOf(buffer, buffer.length + 1); + buffer[buffer.length - 1] = (byte) index; + return buffer; + } + } class RFC7541Parser { private static final HTTP2HeaderField[] STATIC_HEADER_TABLE = new HTTP2HeaderField[62]; + static final Map STATUSES = Map.of( + "200",indexedField(8), + "204",indexedField(9), + "206",indexedField(10), + "304",indexedField(11), + "400",indexedField(12), + "404",indexedField(13), + "500",indexedField(13)); + + private static byte[] indexedField(int index) { + byte[] buffer = new byte[1]; + buffer[0] = (byte) (0x80 | index); + return buffer; + } static { STATIC_HEADER_TABLE[1] = new HTTP2HeaderField(":authority", null); @@ -337,6 +378,18 @@ class RFC7541Parser { STATIC_HEADER_TABLE[61] = new HTTP2HeaderField("www-authenticate", null); } + private static final Map STATIC_HEADER_NAME_TO_INDEX = Arrays.stream(STATIC_HEADER_TABLE) + .filter(f -> f != null) + .collect(java.util.stream.Collectors.toMap( + f -> f.name, + f -> Arrays.asList(STATIC_HEADER_TABLE).indexOf(f), + (a, b) -> a + )); + + public static Integer getIndex(String name) { + return STATIC_HEADER_NAME_TO_INDEX.get(name); + } + public static HTTP2HeaderField getHeaderField(int index) { if (index < 1 || index >= STATIC_HEADER_TABLE.length) { return null; From e59643a0e581a4d65996416ce7b8052cea857b90 Mon Sep 17 00:00:00 2001 From: robert engels Date: Tue, 31 Dec 2024 10:41:49 -0600 Subject: [PATCH 14/78] version 1.0.13 --- README.md | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0163af7..03737bb 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ The counts can be reset using `/__stats?reset`. The `requests/sec` is calculated io.github.robaho httpserver - 1.0.12 + 1.0.13 ``` ## enable Http2 diff --git a/build.gradle b/build.gradle index f869371..08b7258 100644 --- a/build.gradle +++ b/build.gradle @@ -155,7 +155,7 @@ publishing { maven(MavenPublication) { groupId = 'io.github.robaho' artifactId = 'httpserver' - version = "1.0.12" + version = "1.0.13" from components.java From f90f49ab4207672d4999e693f4c1c0b9277dcf1b Mon Sep 17 00:00:00 2001 From: robert engels Date: Tue, 31 Dec 2024 15:01:08 -0600 Subject: [PATCH 15/78] Update README.md --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 03737bb..1e521b2 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,8 @@ TODO: outbound headers are only minimally compressed/indexed. All tests were run on the same hardware with the same JDK23 version. +Using `h2load -n 1000000 -m 1000 -c 16 http://localhost:` + Jetty 11 ``` starting benchmark... @@ -224,4 +226,4 @@ time to 1st byte: 14.13ms 31.81ms 22.80ms 6.61ms 43.75% req/s : 21059.44 21271.63 21141.16 75.01 68.75% ``` - \ No newline at end of file + From 01f35e48ef7bb0fb6302b57dfebc0bd39e50ef0e Mon Sep 17 00:00:00 2001 From: robert engels Date: Thu, 2 Jan 2025 13:03:41 -0600 Subject: [PATCH 16/78] Squashed commit of the following: commit b766f4abd51dfa6ccfa0d9505fca88502d18408d Author: robert engels Date: Thu Jan 2 12:59:32 2025 -0600 further optimizations commit 201100fe9f7107e83aa4699bd0c6420be437f38b Author: robert engels Date: Thu Jan 2 12:59:24 2025 -0600 update timings commit f8045e28c2a7dd4d53f97d478ecd949a514cbd75 Author: robert engels Date: Thu Jan 2 12:34:51 2025 -0600 write header and data frames directly to stream commit f25470098225aa9ec30fb5c54590b38e56122afb Author: robert engels Date: Thu Jan 2 10:48:51 2025 -0600 many micro optimizations, improved performance by 20-30 percent commit dda364aeb0ebd19efb14d17761177cf3303fc1b8 Author: robert engels Date: Wed Jan 1 15:39:28 2025 -0600 remove locking commit 17fad8afbae47670ed4c4af09c3c2e4046d035b2 Author: robert engels Date: Tue Dec 31 15:46:36 2024 -0600 flushes are only 3 per second, so something else is the issue commit 3e3d26e82045eb783a7598216bf0b8a5183fd015 Author: robert engels Date: Tue Dec 31 13:38:45 2024 -0600 try enqueuing outbound frames to avoid contention --- README.md | 52 ++- build.gradle | 9 +- .../httpserver/FixedLengthInputStream.java | 2 +- .../httpserver/FixedLengthOutputStream.java | 2 +- .../net/httpserver/Http2ExchangeImpl.java | 2 +- .../net/httpserver/OpenAddressIntMap.java | 142 ++++++ .../robaho/net/httpserver/OpenAddressMap.java | 110 +++++ .../net/httpserver/OptimizedHeaders.java | 163 +++++++ .../java/robaho/net/httpserver/Request.java | 8 +- .../robaho/net/httpserver/ServerImpl.java | 21 +- .../httpserver/extras/ContentEncoding.java | 2 +- .../net/httpserver/http2/HTTP2Connection.java | 405 +++++++++--------- .../net/httpserver/http2/HTTP2Stream.java | 123 +++--- .../robaho/net/httpserver/http2/Utils.java | 7 +- .../net/httpserver/http2/frame/BaseFrame.java | 10 + .../http2/frame/ContinuationFrame.java | 6 + .../net/httpserver/http2/frame/DataFrame.java | 4 + .../net/httpserver/http2/frame/FrameFlag.java | 19 +- .../httpserver/http2/frame/FrameHeader.java | 53 ++- .../http2/frame/FrameSerializer.java | 13 +- .../net/httpserver/http2/frame/FrameType.java | 20 +- .../httpserver/http2/frame/GoawayFrame.java | 14 +- .../httpserver/http2/frame/HeadersFrame.java | 11 +- .../net/httpserver/http2/frame/PingFrame.java | 14 + .../httpserver/http2/frame/PriorityFrame.java | 7 + .../http2/frame/ResetStreamFrame.java | 12 + .../http2/frame/SettingIdentifier.java | 4 +- .../httpserver/http2/frame/SettingsFrame.java | 10 + .../httpserver/http2/frame/SettingsMap.java | 25 ++ .../http2/frame/WindowUpdateFrame.java | 13 + .../httpserver/http2/hpack/HPackContext.java | 38 +- .../http2/hpack/HTTP2HeaderField.java | 13 + .../httpserver/http2/hpack/HeaderFields.java | 12 +- src/test/extras/SimpleFileServer.java | 2 + src/test/java/FileServerHandler.java | 6 +- 35 files changed, 998 insertions(+), 356 deletions(-) create mode 100644 src/main/java/robaho/net/httpserver/OpenAddressIntMap.java create mode 100644 src/main/java/robaho/net/httpserver/OpenAddressMap.java create mode 100644 src/main/java/robaho/net/httpserver/OptimizedHeaders.java create mode 100644 src/main/java/robaho/net/httpserver/http2/frame/SettingsMap.java diff --git a/README.md b/README.md index 1e521b2..1c7615e 100644 --- a/README.md +++ b/README.md @@ -181,20 +181,20 @@ The http2 implementation passes all specification tests in [h2spec](https://gith ## Http2 performance -Http2 performance has not yet been optimized, but an unscientific test shows the http2 implementation to have greater than 50% better throughput than the Javalin/Jetty 11 version. +Http2 performance has not yet been optimized, but an unscientific test shows the http2 implementation to have greater than 2x better throughput than the Javalin/Jetty 11 version. -The Javalin/Jetty project is available [here](https://github.com/robaho/javalin-http2-example) +Still, the http2 version is almost 3x slower than the http1 version. I expect this to be the case with most http2 implementations. -TODO: outbound headers are only minimally compressed/indexed. +The Javalin/Jetty project is available [here](https://github.com/robaho/javalin-http2-example)
performance details All tests were run on the same hardware with the same JDK23 version. -Using `h2load -n 1000000 -m 1000 -c 16 http://localhost:` +Using `h2load -n 1000000 -m 1000 -c 16 [--h1] http://localhost:` -Jetty 11 +Jetty 11 http2 ``` starting benchmark... spawning thread #0: 16 total client(s). 1000000 total requests @@ -210,20 +210,52 @@ time to 1st byte: 11.16ms 33.62ms 20.95ms 9.28ms 50.00% req/s : 11894.25 12051.63 11957.08 58.94 56.25% ``` +Jetty 11 http1 +``` +starting benchmark... +spawning thread #0: 16 total client(s). 1000000 total requests +Application protocol: http/1.1 +finished in 3.67s, 272138.02 req/s, 35.56MB/s +requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout +status codes: 1000000 2xx, 0 3xx, 0 4xx, 0 5xx +traffic: 130.65MB (137000000) total, 86.78MB (91000000) headers (space savings 0.00%), 10.49MB (11000000) data + min max mean sd +/- sd +time for request: 831us 189.78ms 57.30ms 21.98ms 71.20% +time for connect: 152us 4.21ms 2.19ms 1.24ms 62.50% +time to 1st byte: 4.85ms 11.73ms 7.11ms 2.29ms 81.25% +req/s : 17010.42 17843.23 17334.96 260.43 50.00% +``` + robaho http2 ``` starting benchmark... spawning thread #0: 16 total client(s). 1000000 total requests Application protocol: h2c -finished in 2.97s, 336884.32 req/s, 14.14MB/s +finished in 2.20s, 453632.21 req/s, 19.04MB/s requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout status codes: 1000000 2xx, 0 3xx, 0 4xx, 0 5xx traffic: 41.96MB (44000480) total, 5.72MB (6000000) headers (space savings 76.92%), 10.49MB (11000000) data min max mean sd +/- sd -time for request: 406us 83.67ms 25.15ms 13.16ms 67.28% -time for connect: 188us 11.70ms 5.99ms 3.69ms 56.25% -time to 1st byte: 14.13ms 31.81ms 22.80ms 6.61ms 43.75% -req/s : 21059.44 21271.63 21141.16 75.01 68.75% +time for request: 347us 51.17ms 16.98ms 10.52ms 59.21% +time for connect: 228us 8.77ms 4.02ms 2.44ms 62.50% +time to 1st byte: 9.46ms 22.61ms 12.61ms 4.81ms 81.25% +req/s : 28353.29 29288.55 28542.35 229.27 87.50% +``` + +robaho http1 +``` +starting benchmark... +spawning thread #0: 16 total client(s). 1000000 total requests +Application protocol: http/1.1 +finished in 802.36ms, 1246317.13 req/s, 103.41MB/s +requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout +status codes: 1001066 2xx, 0 3xx, 0 4xx, 0 5xx +traffic: 82.97MB (87000000) total, 46.73MB (49000000) headers (space savings 0.00%), 10.49MB (11000000) data + min max mean sd +/- sd +time for request: 860us 35.46ms 12.61ms 3.33ms 75.21% +time for connect: 92us 4.06ms 2.06ms 1.21ms 62.50% +time to 1st byte: 4.68ms 18.67ms 10.85ms 4.88ms 50.00% +req/s : 77913.01 80438.10 78458.60 721.68 81.25% ```
diff --git a/build.gradle b/build.gradle index 08b7258..47f7045 100644 --- a/build.gradle +++ b/build.gradle @@ -25,6 +25,7 @@ tasks.withType(Test) { jvmArgs += "--add-opens=jdk.httpserver/com.sun.net.httpserver=ALL-UNNAMED" 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.http2OverNonSSL","true") // systemProperty("robaho.net.httpserver.http2MaxConcurrentStreams","5000") // systemProperty("robaho.net.httpserver.http2DisableFlushDelay","true") @@ -37,10 +38,6 @@ tasks.withType(JavaExec) { systemProperty("com.sun.net.httpserver.HttpServerProvider","robaho.net.httpserver.DefaultHttpServerProvider") } -tasks.withType(JavaExec).configureEach { - javaLauncher.set(javaToolchains.launcherFor(java.toolchain)) -} - dependencies { testImplementation 'org.testng:testng:7.8.0' } @@ -122,7 +119,11 @@ task testSingleTest(type: Test) { } } + task runSimpleFileServer(type: Test) { + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(23) + } dependsOn testClasses doLast { def props = systemProperties diff --git a/src/main/java/robaho/net/httpserver/FixedLengthInputStream.java b/src/main/java/robaho/net/httpserver/FixedLengthInputStream.java index d3f22ea..b1a64cc 100644 --- a/src/main/java/robaho/net/httpserver/FixedLengthInputStream.java +++ b/src/main/java/robaho/net/httpserver/FixedLengthInputStream.java @@ -39,7 +39,7 @@ class FixedLengthInputStream extends LeftOverInputStream { FixedLengthInputStream(ExchangeImpl t, InputStream src, long len) { super(t, src); if (len < 0) { - throw new IllegalArgumentException("Content-Length: " + len); + throw new IllegalArgumentException("Content-length: " + len); } this.remaining = len; } diff --git a/src/main/java/robaho/net/httpserver/FixedLengthOutputStream.java b/src/main/java/robaho/net/httpserver/FixedLengthOutputStream.java index 85c00b3..dcef65d 100644 --- a/src/main/java/robaho/net/httpserver/FixedLengthOutputStream.java +++ b/src/main/java/robaho/net/httpserver/FixedLengthOutputStream.java @@ -44,7 +44,7 @@ class FixedLengthOutputStream extends FilterOutputStream { FixedLengthOutputStream(ExchangeImpl t, OutputStream src, long len) { super(src); if (len < 0) { - throw new IllegalArgumentException("Content-Length: " + len); + throw new IllegalArgumentException("Content-length: " + len); } this.t = t; this.remaining = len; diff --git a/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java b/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java index 30c41ff..cbb3d7f 100644 --- a/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java +++ b/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java @@ -79,7 +79,7 @@ public void close() { @Override public void sendResponseHeaders(int rCode, long responseLength) throws IOException { if(responseLength>0) { - response.set("Content-Length", Long.toString(responseLength)); + response.set("Content-length", Long.toString(responseLength)); } else if(responseLength==0) { // no chunked encoding so just ignore } else { diff --git a/src/main/java/robaho/net/httpserver/OpenAddressIntMap.java b/src/main/java/robaho/net/httpserver/OpenAddressIntMap.java new file mode 100644 index 0000000..b60d0f2 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/OpenAddressIntMap.java @@ -0,0 +1,142 @@ +package robaho.net.httpserver; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +public class OpenAddressIntMap { + + private static class Entry { + int key; + Object value; + + Entry(int key, Object value) { + this.key = key; + this.value = value; + } + } + + private int capacity; + private int mask; + private Entry[] entries; + private int size; + private int used; + + public OpenAddressIntMap(int capacity) { + // round up to next power of 2 + capacity--; + capacity |= capacity >> 1; + capacity |= capacity >> 2; + capacity |= capacity >> 4; + capacity |= capacity >> 8; + capacity |= capacity >> 16; + capacity++; + + this.capacity = capacity; + this.mask = capacity - 1; + this.entries = new Entry[capacity]; + } + + public synchronized T put(int key, T value) { + if(used>=capacity/2) { + resize(); + } + int index = key & mask; + int start = index; + int sentinel = -1; + Entry entry; + while ((entry = entries[index]) != null) { + if (entry.key==key) { + T oldValue = (T)entry.value; + entry.value = value; + if(value==null) { + size--; + } + return oldValue; + } else if (entry.value==null) { + sentinel = index; + } + index = (index + 1) & mask; + if (index == start) { + resize(); + index = key & mask; + start = index; + sentinel = -1; + } + } + if(value==null) { + return null; + } + entries[sentinel==-1 ? index : sentinel] = new Entry(key, value); + size++; used++; + return null; + } + + private void resize() { + OpenAddressIntMap newMap = new OpenAddressIntMap(capacity << 1); + for (Entry entry : entries) { + if (entry != null && entry.value != null) { + newMap.put(entry.key, entry.value); + } + } + this.entries = newMap.entries; + this.capacity = newMap.capacity; + this.mask = newMap.mask; + this.size = newMap.size; + this.used = newMap.used; + } + + public T get(int key) { + int index = key & mask; + int start = index; + Entry entry; + while ((entry = entries[index]) != null) { + if (entry.key==key) { + return (T)entry.value; + } + index = (index + 1) & mask; + if(index==start) { + break; + } + } + return null; + } + + public T getOrDefault(int key, T defaultValue) { + T value = get(key); + return value != null ? value : defaultValue; + } + + public int size() { + return size; + } + + public void clear() { + Arrays.fill(entries, null); + } + + public Iterable values() { + List result = new ArrayList<>(); + for (Entry entry : entries) { + if (entry != null && entry.value != null) { + result.add((T)entry.value); + } + } + return Collections.unmodifiableList(result); + } + + public Set> entrySet(Function valueMapper) { + Set> result = new HashSet<>(); + for (Entry entry : entries) { + if (entry != null) { + result.add(Map.entry(entry.key, valueMapper != null ? valueMapper.apply((T)entry.value) : (T2) entry.value)); + } + } + return Collections.unmodifiableSet(result); + } +} diff --git a/src/main/java/robaho/net/httpserver/OpenAddressMap.java b/src/main/java/robaho/net/httpserver/OpenAddressMap.java new file mode 100644 index 0000000..24f4240 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/OpenAddressMap.java @@ -0,0 +1,110 @@ +package robaho.net.httpserver; + +import java.util.Arrays; +import java.util.function.BiConsumer; + +public class OpenAddressMap { + + private static class Entry { + + String key; + Object value; + + Entry(String key, Object value) { + this.key = key; + this.value = value; + } + } + + private int capacity; + private int mask; + private int size; + private Entry[] entries; + + public OpenAddressMap(int capacity) { + // round up to next power of 2 + capacity--; + capacity |= capacity >> 1; + capacity |= capacity >> 2; + capacity |= capacity >> 4; + capacity |= capacity >> 8; + capacity |= capacity >> 16; + capacity++; + + this.capacity = capacity; + this.mask = capacity - 1; + this.entries = new Entry[capacity]; + } + + public Object put(String key, Object value) { + int index = key.hashCode() & mask; + int start = index; + int sentinel = -1; + Entry entry; + while ((entry = entries[index]) != null) { + if (entry.key.equals(key)) { + Object oldValue = entry.value; + entry.value = value; + if (value == null) { + size--; + } + return oldValue; + } else if (entry.value == null) { + sentinel = index; + } + index = (index + 1) & mask; + if (index == start) { + resize(); + index = key.hashCode() & mask; + start = index; + } + } + entries[sentinel==-1 ? index : sentinel] = new Entry(key, value); + size++; + return null; + } + + private void resize() { + OpenAddressMap newMap = new OpenAddressMap(capacity << 1); + for (Entry entry : entries) { + if (entry != null) { + newMap.put(entry.key, entry.value); + } + } + this.entries = newMap.entries; + this.capacity = newMap.capacity; + this.mask = newMap.mask; + } + + public Object get(String key) { + int index = key.hashCode() & mask; + int start = index; + Entry entry; + while ((entry = entries[index]) != null) { + if (entry.key.equals(key)) { + return entry.value; + } + index = (index + 1) & mask; + if(index==start) { + break; + } + } + return null; + } + + public int size() { + return size; + } + + public void clear() { + Arrays.fill(entries, null); + } + + public void forEach(BiConsumer action) { + for (Entry entry : entries) { + if (entry != null && entry.value != null) { + action.accept(entry.key,entry.value); + } + } + } +} diff --git a/src/main/java/robaho/net/httpserver/OptimizedHeaders.java b/src/main/java/robaho/net/httpserver/OptimizedHeaders.java new file mode 100644 index 0000000..ffab94a --- /dev/null +++ b/src/main/java/robaho/net/httpserver/OptimizedHeaders.java @@ -0,0 +1,163 @@ +package robaho.net.httpserver; + +import java.util.AbstractMap; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashSet; +import java.util.stream.Collectors; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; + +import com.sun.net.httpserver.Headers; + +public class OptimizedHeaders extends Headers { + private final OpenAddressMap map; + public OptimizedHeaders() { + super(); + map = new OpenAddressMap(16); + } + public OptimizedHeaders(int capacity) { + super(); + map = new OpenAddressMap(capacity); + } + @Override + public int size() { + return map.size(); + } + + @Override + public boolean isEmpty() { + return map.size() == 0; + } + + @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; + } + + @Override + public List put(String key, List value) { + Object o = map.put(normalize((String)key), value); + return o == null ? null : (o instanceof String) ? Arrays.asList((String)o) : (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; + } + + @Override + public String getFirst(String key) { + Object o = map.get(normalize((String)key)); + return o == null ? null : (o instanceof String) ? (String)o : ((List)o).getFirst(); + } + + /** + * Normalize the key by converting to following form. + * First {@code char} upper case, rest lower case. + * key is presumed to be {@code ASCII}. + */ + private String normalize(String key) { + int len = key.length(); + if(len==0) return key; + + int i=0; + + for(;i= 'a' && c <= 'z') { + break; + } + } else { + if (c >= 'A' && c <= 'Z') { + break; + } + } + } + if(i==len) return key; + + System.out.println("normalizing key: " + key); + + char[] buffer = key.toCharArray(); + for(;i= 'a' && c <= 'z') { + buffer[i] = (char)(c - ('a' - 'A')); + } else if (i!=0 && c >= 'A' && c <= 'Z') { + buffer[i] = (char) (c + ('a' - 'A')); + } else if (c == '\r' || c == '\n') + throw new IllegalArgumentException("illegal character in key"); + } + return new String(buffer); + } + + @Override + public void add(String key, String value) { + Object o = map.get(normalize((String)key)); + if (o == null) { + map.put(normalize(key), value); + } else if(o instanceof String) { + map.put(normalize(key), Arrays.asList((String)o,value)); + } else { + ((List)o).add(value); + } + } + + @Override + public void clear() { + map.clear(); + } + + @Override + public void set(String key, String value) { + map.put(normalize(key), value); + } + + @Override + public boolean containsKey(Object key) { + return map.get(normalize((String)key)) != null; + } + + @Override + public boolean containsValue(Object value) { + return entrySet().stream().anyMatch(e -> e.getValue().contains((String)value)); + } + @Override + public Set keySet() { + return entrySet().stream().map(e -> e.getKey()).collect(Collectors.toSet()); + } + + @Override + public Collection> values() { + return entrySet().stream().map(e -> e.getValue()).collect(Collectors.toSet()); + } + + @Override + public Set>> entrySet() { + Set>> set = new HashSet(); + forEach((k,v) -> set.add(new AbstractMap.SimpleEntry<>(k,v))); + return set; + } + + @Override + public boolean equals(Object o) { + return (this == o); + } + + @Override + public int hashCode() { + return map.hashCode(); + } + + @Override + public void forEach(BiConsumer> action) { + map.forEach((k,v) -> action.accept(k, (v instanceof String) ? List.of((String)v) : (List)v)); + } +} \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/Request.java b/src/main/java/robaho/net/httpserver/Request.java index a6967ce..d0880fc 100644 --- a/src/main/java/robaho/net/httpserver/Request.java +++ b/src/main/java/robaho/net/httpserver/Request.java @@ -182,7 +182,7 @@ Headers headers() throws IOException { if (hdrs != null) { return hdrs; } - hdrs = new Headers(); + hdrs = new OptimizedHeaders(16); BufferedBuilder key = new BufferedBuilder(32); BufferedBuilder value = new BufferedBuilder(128); @@ -221,7 +221,11 @@ else if(c==LF && prevCR) { current=value; pbs.skipWhitespace(); } else { - current.append((char)c); + if(current==key) { + key.append((char)(current.count==0 ? Character.toUpperCase(c) : Character.toLowerCase(c))); + } else { + current.append((char)c); + } } } } diff --git a/src/main/java/robaho/net/httpserver/ServerImpl.java b/src/main/java/robaho/net/httpserver/ServerImpl.java index b261e35..61b1d8e 100644 --- a/src/main/java/robaho/net/httpserver/ServerImpl.java +++ b/src/main/java/robaho/net/httpserver/ServerImpl.java @@ -552,20 +552,15 @@ public void handleStream(HTTP2Stream stream,InputStream in, OutputStream out) th throw new IOException("Invalid HTTP/2 headers: missing :authority or :path"); } - StringBuilder uriBuilder = new StringBuilder(); - uriBuilder.append(scheme).append("://").append(authority).append(path); - if (query != null) { - uriBuilder.append("?").append(query); - } - request.add("Host",authority); URI uri; try { - uri = new URI(uriBuilder.toString()); - } catch (URISyntaxException e) { - throw new IOException("Invalid URI syntax", e); + uri = new URI(scheme,authority,path,query,null); + } catch (URISyntaxException ex) { + throw new IOException("invalid uri",ex); } + String method = request.getFirst(":method"); if (method == null) { throw new IOException("Invalid HTTP/2 headers: missing :method"); @@ -758,7 +753,7 @@ private void runPerRequest() throws IOException { } if (clen < 0) { reject(Code.HTTP_BAD_REQUEST, requestLine, - "Illegal Content-Length value"); + "Illegal Content-length value"); return; } } @@ -857,11 +852,11 @@ void sendReply( .append(code).append(Code.msg(code)).append("\r\n"); if (text != null && text.length() != 0) { - builder.append("Content-Length: ") + builder.append("Content-length: ") .append(text.length()).append("\r\n") - .append("Content-Type: text/html\r\n"); + .append("Content-type: text/html\r\n"); } else { - builder.append("Content-Length: 0\r\n"); + builder.append("Content-length: 0\r\n"); text = ""; } if (closeNow) { diff --git a/src/main/java/robaho/net/httpserver/extras/ContentEncoding.java b/src/main/java/robaho/net/httpserver/extras/ContentEncoding.java index 9ee2eb1..9b6b3e0 100644 --- a/src/main/java/robaho/net/httpserver/extras/ContentEncoding.java +++ b/src/main/java/robaho/net/httpserver/extras/ContentEncoding.java @@ -13,7 +13,7 @@ public class ContentEncoding { * @return the provided content encoding or the default */ public static String encoding(Headers headers) { - List values = headers.get("content-encoding"); + List values = headers.get("Content-encoding"); if (values == null) { return defaultCharset; } diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java index cd4eaff..9eb40d7 100644 --- a/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java @@ -9,21 +9,20 @@ import java.net.InetSocketAddress; import java.util.ArrayList; import java.util.EnumSet; -import java.util.HashMap; -import java.util.HashSet; import java.util.List; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.Executor; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.LockSupport; import java.util.concurrent.locks.ReentrantLock; import com.sun.net.httpserver.Headers; import robaho.net.httpserver.HttpConnection; +import robaho.net.httpserver.OpenAddressIntMap; +import robaho.net.httpserver.OptimizedHeaders; import robaho.net.httpserver.ServerConfig; import robaho.net.httpserver.http2.hpack.HPackContext; import robaho.net.httpserver.http2.hpack.HTTP2HeaderField; @@ -42,28 +41,26 @@ import robaho.net.httpserver.http2.frame.SettingIdentifier; import robaho.net.httpserver.http2.frame.SettingParameter; import robaho.net.httpserver.http2.frame.SettingsFrame; +import robaho.net.httpserver.http2.frame.SettingsMap; import robaho.net.httpserver.http2.frame.WindowUpdateFrame; public class HTTP2Connection { - static final String PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"; - static final String PARTIAL_PREFACE = "\r\nSM\r\n\r\n"; + static final String PREFACE = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"; + static final String PARTIAL_PREFACE = "\r\nSM\r\n\r\n"; - final private InputStream inputStream; - final OutputStream outputStream; + final private InputStream inputStream; + final OutputStream outputStream; - private int lastSeenStreamId = 0; + private int lastSeenStreamId = 0; - final ConcurrentMap http2Streams = new ConcurrentHashMap<>(); - private final Set previousStreams = new HashSet(); + final OpenAddressIntMap http2Streams = new OpenAddressIntMap(16); - private final HashMap remoteSettings = new HashMap<>(); - private final HashMap localSettings = new HashMap<>(); + private final SettingsMap remoteSettings = new SettingsMap(); + private final SettingsMap localSettings = new SettingsMap(); private final StreamHandler handler; - private final Lock lock = new ReentrantLock(); - final HttpConnection httpConnection; final Logger logger; @@ -76,31 +73,40 @@ public class HTTP2Connection { private int maxConcurrentStreams = -1; private int highNumberStreams = 0; - /** - * Constructor to instantiate HTTP2Connection object - * - * @param input - * HTTP2Client passes the ExBufferedInputStream - * @param output - */ - public HTTP2Connection(HttpConnection httpConnection,InputStream input, OutputStream output, StreamHandler handler) { + private final Lock lock = new ReentrantLock(); + + /** + * Constructor to instantiate HTTP2Connection object + * + * @param input HTTP2Client passes the ExBufferedInputStream + * @param output + */ + public HTTP2Connection(HttpConnection httpConnection, InputStream input, OutputStream output, StreamHandler handler) { this.httpConnection = httpConnection; - this.inputStream = input; - this.outputStream = output; + this.inputStream = input; + this.outputStream = output; this.handler = handler; this.logger = System.getLogger("robaho.net.httpserver.http2"); - localSettings.put(SettingIdentifier.SETTINGS_MAX_FRAME_SIZE,new SettingParameter(SettingIdentifier.SETTINGS_MAX_FRAME_SIZE,ServerConfig.http2MaxFrameSize())); - localSettings.put(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE,new SettingParameter(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE,ServerConfig.http2InitialWindowSize())); - if(ServerConfig.http2MaxConcurrentStreams()!=-1) { - localSettings.put(SettingIdentifier.SETTINGS_MAX_CONCURRENT_STREAMS,new SettingParameter(SettingIdentifier.SETTINGS_MAX_CONCURRENT_STREAMS,ServerConfig.http2MaxConcurrentStreams())); + localSettings.set(new SettingParameter(SettingIdentifier.SETTINGS_MAX_FRAME_SIZE, ServerConfig.http2MaxFrameSize())); + localSettings.set(new SettingParameter(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE, ServerConfig.http2InitialWindowSize())); + if (ServerConfig.http2MaxConcurrentStreams() != -1) { + localSettings.set(new SettingParameter(SettingIdentifier.SETTINGS_MAX_CONCURRENT_STREAMS, ServerConfig.http2MaxConcurrentStreams())); } - logger.log(Level.DEBUG,"opened http2 connection "+httpConnection+", max concurrent streams "+ServerConfig.http2MaxConcurrentStreams()); - } + logger.log(Level.DEBUG, "opened http2 connection " + httpConnection + ", max concurrent streams " + ServerConfig.http2MaxConcurrentStreams()); + } + + void lock() { + lock.lock(); + } + + void unlock() { + lock.unlock(); + } @Override public String toString() { - return "{" + httpConnection +", streams=" + http2Streams.size()+", high "+highNumberStreams+"}"; + return "{" + httpConnection + ", streams=" + http2Streams.size() + ", high " + highNumberStreams + "}"; } public void close() { @@ -109,45 +115,60 @@ public void close() { } } - public HashMap getRemoteSettings() { - return remoteSettings; - } - - public HashMap getLocalSettings() { - return localSettings; - } - - /** - * Function to validate the PREFACE received on the input stream from the - * remote system - * - * @return true if preface is valid - * @throws IOException - */ - public boolean hasProperPreface() throws IOException { + public SettingsMap getRemoteSettings() { + return remoteSettings; + } + + public SettingsMap getLocalSettings() { + return localSettings; + } + + public void writeFrame(byte[] frame) throws IOException { + writeFrame(List.of(frame)); + } + + /** + * writes a frame that consists of multiple byte arrays. the method is + * designed for low-volume frames where the overhead of creating a new byte + * array is minimal. Otherwise, the data/frame should be written directly to + * the output stream, e.g. see HTTP2Stream.Http2OutputStream + */ + public void writeFrame(List partials) throws IOException { + lock(); + try { + logger.log(Level.TRACE, () -> "sending frame " + FrameHeader.debug(partials.get(0))); + for (var frame : partials) { + outputStream.write(frame); + } + } finally { + unlock(); + } + } + + /** + * Function to validate the PREFACE received on the input stream from the + * remote system + * + * @return true if preface is valid + * @throws IOException + */ + public boolean hasProperPreface() throws IOException { String preface_match = (httpConnection.isSSL()) ? PREFACE : PARTIAL_PREFACE; byte[] preface = new byte[preface_match.length()]; inputStream.read(preface); String prefaceStr = new String(preface, 0, preface.length); return prefaceStr.equals(preface_match); - } - - void lock() { - lock.lock(); - } - void unlock() { - lock.unlock(); } public boolean isClosed() { return httpConnection.isClosed(); } - public void handle() throws Exception { + public void handle() throws Exception { try { processFrames(); } catch (HTTP2Exception e) { - logger.log(Level.DEBUG,"exception on http2 connection",e); + logger.log(Level.DEBUG, "exception on http2 connection", e); sendGoAway(e.getErrorCode()); throw e; } @@ -159,25 +180,24 @@ private void processFrames() throws Exception { List headerBlockFragments = new ArrayList(); - // main HTTP2 - while (!httpConnection.isClosed()) { + // main HTTP2 + while (!httpConnection.isClosed()) { BaseFrame frame = FrameSerializer.deserialize(inputStream); // System.out.println("Received frame: " + frame.getHeader()); int streamId = frame.getHeader().getStreamIdentifier(); if (streamId != 0 && streamId % 2 == 0) { - throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"invalid stream id " + streamId+ " on type " + frame.getHeader().getType()); + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "invalid stream id " + streamId + " on type " + frame.getHeader().getType()); } - // rfc7540 (section 6.5): SETTINGS frames always apply to a - // connection, never a single stream. - + // rfc7540 (section 6.5): SETTINGS frames always apply to a + // connection, never a single stream. switch (frame.getHeader().getType()) { case SETTINGS: if (frame.getHeader().getFlags().contains(FrameFlag.ACK)) { - if(ServerConfig.http2MaxConcurrentStreams()!=-1) { + if (ServerConfig.http2MaxConcurrentStreams() != -1) { // cannot set this until it's been acked - maxConcurrentStreams = http2Streams.size()+ServerConfig.http2MaxConcurrentStreams(); + maxConcurrentStreams = http2Streams.size() + ServerConfig.http2MaxConcurrentStreams(); } continue; } else { @@ -187,29 +207,29 @@ private void processFrames() throws Exception { continue; case GOAWAY: GoawayFrame goaway = (GoawayFrame) frame; - if(goaway.errorCode==HTTP2ErrorCode.NO_ERROR) { + if (goaway.errorCode == HTTP2ErrorCode.NO_ERROR) { continue; } - throw new IOException("received GOAWAY from remote "+goaway.errorCode); + throw new IOException("received GOAWAY from remote " + goaway.errorCode); case PING: if (!frame.getHeader().getFlags().contains(FrameFlag.ACK)) { sendPingAck((PingFrame) frame); } continue; case WINDOW_UPDATE: - if (frame.getHeader().getStreamIdentifier()== 0) { - int windowSizeIncrement = ((WindowUpdateFrame)frame).getWindowSizeIncrement(); + if (streamId == 0) { + int windowSizeIncrement = ((WindowUpdateFrame) frame).getWindowSizeIncrement(); sendWindow.addAndGet(windowSizeIncrement); - logger.log(Level.DEBUG,"received connection window update "+windowSizeIncrement+", new size "+sendWindow.get()); - if(sendWindow.get() > 2147483647) { - throw new HTTP2Exception(HTTP2ErrorCode.FLOW_CONTROL_ERROR,"maximum window size exceeded"); + logger.log(Level.DEBUG, "received connection window update " + windowSizeIncrement + ", new size " + sendWindow.get()); + if (sendWindow.get() > 2147483647) { + throw new HTTP2Exception(HTTP2ErrorCode.FLOW_CONTROL_ERROR, "maximum window size exceeded"); } continue; } break; case NOT_IMPLEMENTED: - if(inHeaders) { - throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"NOT_IMPLEMENTED frame received while headers being received"); + if (inHeaders) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "NOT_IMPLEMENTED frame received while headers being received"); } if (frame.getHeader().getStreamIdentifier() == 0) { continue; @@ -217,248 +237,217 @@ private void processFrames() throws Exception { break; case DATA: DataFrame dataFrame = (DataFrame) frame; - if(receiveWindow.addAndGet(-dataFrame.body.length)<=0) { + if (receiveWindow.addAndGet(-dataFrame.body.length) <= 0) { sendWindowUpdate(); } - if(inHeaders) { - throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"DATA frame received while headers being received"); + if (inHeaders) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "DATA frame received while headers being received"); } break; case HEADERS: - if(inHeaders) { - throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"HEADERS frame received on open stream"); + if (inHeaders) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "HEADERS frame received on open stream"); } - if(streamId < lastSeenStreamId) { - throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"HEADERS frame received out of order"); + if (streamId < lastSeenStreamId) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "HEADERS frame received out of order"); } var stream = http2Streams.get(streamId); - if(stream!=null) { - if(!stream.isOpen() || stream.isHalfClosed()) { - throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED,"HEADERS frame received on already closed stream"); + if (stream != null) { + if (!stream.isOpen() || stream.isHalfClosed()) { + throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED, "HEADERS frame received on already closed stream"); } else { - throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"HEADERS frame received on already established stream"); + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "HEADERS frame received on already established stream"); } } HeadersFrame headersFrame = (HeadersFrame) frame; headerBlockFragments.add(headersFrame.getHeaderBlock()); - if(!headersFrame.getHeader().getFlags().contains(FrameFlag.END_HEADERS)) { + if (!headersFrame.getHeader().getFlags().contains(FrameFlag.END_HEADERS)) { inHeaders = true; openStreamId = streamId; continue; } break; case CONTINUATION: - if(inHeaders && frame.getHeader().getStreamIdentifier() != openStreamId) { - throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"HEADERS frame received on open stream"); + if (inHeaders && frame.getHeader().getStreamIdentifier() != openStreamId) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "HEADERS frame received on open stream"); + } + if (!inHeaders) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "CONTINUATION frame received on closed stream"); } - if(!inHeaders) { - throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"CONTINUATION frame received on closed stream"); - } ContinuationFrame continuationFrame = (ContinuationFrame) frame; headerBlockFragments.add(continuationFrame.getHeaderBlock()); - if(!continuationFrame.getHeader().getFlags().contains(FrameFlag.END_HEADERS)) { + if (!continuationFrame.getHeader().getFlags().contains(FrameFlag.END_HEADERS)) { continue; } break; case PRIORITY: - if(inHeaders) { - throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"PRIORITY frame received during headers receive"); + if (inHeaders) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "PRIORITY frame received during headers receive"); } - if(streamId == 0) { - throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"PRIORITY frame received on stream 0"); + if (streamId == 0) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "PRIORITY frame received on stream 0"); } // stream priority is ignore for now continue; case RST_STREAM: ResetStreamFrame resetFrame = (ResetStreamFrame) frame; - if(resetFrame.errorCode==HTTP2ErrorCode.NO_ERROR) { + if (resetFrame.errorCode == HTTP2ErrorCode.NO_ERROR) { continue; } - if(!http2Streams.containsKey(streamId)) { - if(previousStreams.contains(streamId)) continue; - throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"RST_STREAM frame received on non-existent stream"); + if (streamId == 0) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "RST_STREAM frame received on stream 0"); + } + if (http2Streams.get(streamId) == null) { + if (streamId <= lastSeenStreamId) { + continue; + } + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "RST_STREAM frame received on non-existent stream"); } break; } // we can only get here if we have a complete set of headers + HTTP2Stream targetStream = http2Streams.get(streamId); - HTTP2Stream targetStream = null; - - // check if streamID is already present in the hashmap, i.e., a - // already established stream - - if (http2Streams.containsKey(streamId)) { - targetStream = http2Streams.get(streamId); + if (targetStream != null) { + // found existing stream } else if (lastSeenStreamId < streamId) { int currentSize = http2Streams.size(); - if(maxConcurrentStreams!=-1 && currentSize>=maxConcurrentStreams) { + if (maxConcurrentStreams != -1 && currentSize >= maxConcurrentStreams) { throw new HTTP2Exception(HTTP2ErrorCode.REFUSED_STREAM); } - highNumberStreams = Math.max(highNumberStreams,currentSize); + highNumberStreams = Math.max(highNumberStreams, currentSize); byte[] headerBlock = Utils.combineByteArrays(headerBlockFragments); HeaderFields fields = new HeaderFields(); fields.addAll(hpack.decodeFieldSegments(headerBlock)); // streamID is not present and has to be greater than all // the stream IDs present fields.validate(); - Headers requestHeaders = new Headers(); - for(HTTP2HeaderField field : fields) { - if(field.value==null) { - logger.log(Level.TRACE,() -> "ignoring null header for "+field.getName()); + Headers requestHeaders = new OptimizedHeaders(fields.size()); + for (HTTP2HeaderField field : fields) { + if (field.value == null) { + logger.log(Level.TRACE, () -> "ignoring null header for " + field.getName()); } else { - requestHeaders.add(field.name,field.value); + requestHeaders.add(field.normalizedName, field.value); } } headerBlockFragments.clear(); inHeaders = false; targetStream = new HTTP2Stream(streamId, this, requestHeaders, handler); http2Streams.put(streamId, targetStream); - previousStreams.add(streamId); lastSeenStreamId = streamId; } else { - if(previousStreams.contains(streamId)) { - throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED,"stream "+streamId+" is closed"); + if (streamId < lastSeenStreamId) { + throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED, "stream " + streamId + " is closed"); } - throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"Stream ID not in order"); + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "Stream ID not in order"); } - if (targetStream != null) { - targetStream.processFrame(frame); - } - } - } + targetStream.processFrame(frame); + } + } - public void updateRemoteSettings(SettingsFrame remoteSettingFrame) throws HTTP2Exception { - logger.log(Level.TRACE,() -> "updating remote settings"); + public void updateRemoteSettings(SettingsFrame remoteSettingFrame) throws HTTP2Exception { + logger.log(Level.TRACE, () -> "updating remote settings"); for (SettingParameter parameter : remoteSettingFrame.getSettingParameters()) { long oldInitialWindowSize = remoteSettings.getOrDefault(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE, SettingParameter.DEFAULT_INITIAL_WINDOWSIZE).value; - if(parameter.identifier == SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE) { - if(parameter.value > 2147483647) { - throw new HTTP2Exception(HTTP2ErrorCode.FLOW_CONTROL_ERROR,"Invalid value for SETTINGS_INITIAL_WINDOW_SIZE "+parameter.value); + if (parameter.identifier == SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE) { + if (parameter.value > 2147483647) { + throw new HTTP2Exception(HTTP2ErrorCode.FLOW_CONTROL_ERROR, "Invalid value for SETTINGS_INITIAL_WINDOW_SIZE " + parameter.value); } - logger.log(Level.DEBUG,() -> "received initial window size of "+parameter.value); - for(var stream : http2Streams.values()) { - stream.sendWindow.addAndGet(parameter.value-oldInitialWindowSize); + logger.log(Level.DEBUG, () -> "received initial window size of " + parameter.value); + for (var stream : http2Streams.values()) { + stream.sendWindow.addAndGet(parameter.value - oldInitialWindowSize); } } - if(parameter.identifier == SettingIdentifier.SETTINGS_MAX_FRAME_SIZE) { - logger.log(Level.DEBUG,() -> "received max frame size "+parameter.value); + if (parameter.identifier == SettingIdentifier.SETTINGS_MAX_FRAME_SIZE) { + logger.log(Level.DEBUG, () -> "received max frame size " + parameter.value); } - getRemoteSettings().put(parameter.identifier, parameter); + getRemoteSettings().set(parameter); } - } + } - public void sendSettingsAck() throws IOException { - logger.log(Level.TRACE,() -> "sending Settings Ack"); - lock(); + public void sendSettingsAck() throws IOException { try { - SettingsFrame frame = new SettingsFrame(); - frame.writeTo(outputStream); - outputStream.flush(); + byte[] frame = FrameHeader.encode(0, FrameType.SETTINGS, EnumSet.of(FrameFlag.ACK), 0); + HTTP2Connection.this.writeFrame(frame); } finally { - unlock(); - logger.log(Level.TRACE,() -> "sent Settings Ack"); + logger.log(Level.TRACE, () -> "sent Settings Ack"); } - } - public void sendMySettings() throws IOException { - lock(); + } + + public void sendMySettings() throws IOException { try { - FrameHeader header = new FrameHeader(0, FrameType.SETTINGS, EnumSet.noneOf(FrameFlag.class), 0); + FrameHeader header = new FrameHeader(0, FrameType.SETTINGS, FrameFlag.NONE, 0); SettingsFrame frame = new SettingsFrame(header); - for(var setting : localSettings.values()) { - frame.getSettingParameters().add(setting); - } - frame.writeTo(outputStream); - outputStream.flush(); + localSettings.forEach(setting -> frame.getSettingParameters().add(setting)); + HTTP2Connection.this.writeFrame(frame.encode()); } finally { - unlock(); - logger.log(Level.TRACE,() -> "Sent My Settings"); + logger.log(Level.TRACE, () -> "Sent My Settings"); } - } - public void sendWindowUpdate() throws IOException { - lock(); + } + + public void sendWindowUpdate() throws IOException { try { receiveWindow.addAndGet(65535); - FrameHeader header = new FrameHeader(4, FrameType.WINDOW_UPDATE, EnumSet.noneOf(FrameFlag.class), 0); - WindowUpdateFrame frame = new WindowUpdateFrame(header); - frame.writeTo(outputStream); - Utils.writeBinary(outputStream, 65535, 4); - outputStream.flush(); + WindowUpdateFrame frame = new WindowUpdateFrame(0, 65535); + HTTP2Connection.this.writeFrame(frame.encode()); } finally { - unlock(); - logger.log(Level.TRACE,() -> "Sent My Settings"); + logger.log(Level.TRACE, () -> "Sent My Settings"); } - } + } InetSocketAddress getRemoteAddress() { return (InetSocketAddress) httpConnection.getRemoteAddress(); } + InetSocketAddress getLocalAddress() { return (InetSocketAddress) httpConnection.getLocalAddress(); } public void sendGoAway(HTTP2ErrorCode errorCode) throws IOException { - FrameHeader header = new FrameHeader(8, FrameType.GOAWAY, EnumSet.noneOf(FrameFlag.class), 0); lock(); try { - header.writeTo(outputStream); - Utils.writeBinary(outputStream, lastSeenStreamId, 4); - Utils.writeBinary(outputStream, errorCode.value, 4); + GoawayFrame frame = new GoawayFrame(errorCode, lastSeenStreamId); + frame.writeTo(outputStream); outputStream.flush(); } finally { unlock(); - logger.log(Level.TRACE,() -> "Sent GoAway "+errorCode); } + logger.log(Level.TRACE, () -> "Sent GoAway " + errorCode + ", last stream " + lastSeenStreamId); } - public void sendResetStream(HTTP2ErrorCode errorCode,int streamId) throws IOException { - FrameHeader header = new FrameHeader(4, FrameType.RST_STREAM, EnumSet.noneOf(FrameFlag.class), streamId); - lock(); - try { - header.writeTo(outputStream); - Utils.writeBinary(outputStream, errorCode.value, 4); - outputStream.flush(); - } finally { - unlock(); - logger.log(Level.TRACE,() -> "Sent Reset Stream "+streamId); - } + + public void sendResetStream(HTTP2ErrorCode errorCode, int streamId) throws IOException { + ResetStreamFrame frame = new ResetStreamFrame(errorCode, streamId); + HTTP2Connection.this.writeFrame(frame.encode()); + logger.log(Level.TRACE, () -> "Sent ResetStream " + errorCode); } public void sendPing() throws IOException { - FrameHeader header = new FrameHeader(8, FrameType.PING, EnumSet.noneOf(FrameFlag.class), 0); - lock(); - try { - header.writeTo(outputStream); - Utils.writeBinary(outputStream, 0, 4); - Utils.writeBinary(outputStream, 0, 4); - outputStream.flush(); - } finally { - unlock(); - logger.log(Level.TRACE,"Sent Ping"); - } + PingFrame frame = new PingFrame(); + HTTP2Connection.this.writeFrame(frame.encode()); + logger.log(Level.TRACE, () -> "Sent Ping "); } - private void sendPingAck(PingFrame frame) throws IOException { - lock(); - try { - FrameHeader header = new FrameHeader(frame.body.length, FrameType.PING, EnumSet.of(FrameFlag.ACK), 0); - header.writeTo(outputStream); - outputStream.write(frame.body); - outputStream.flush(); - } finally { - unlock(); - logger.log(Level.TRACE,"Sent Ping Ack"); - } + + private void sendPingAck(PingFrame ping) throws IOException { + PingFrame frame = new PingFrame(ping); + HTTP2Connection.this.writeFrame(frame.encode()); + logger.log(Level.TRACE, "Sent Ping Ack"); } public static interface StreamHandler { - void handleStream(HTTP2Stream stream,InputStream in,OutputStream out) throws IOException; + + void handleStream(HTTP2Stream stream, InputStream in, OutputStream out) throws IOException; + Executor getExecutor(); } public static void readFully(InputStream inputStream, byte[] buffer) throws IOException { - if(buffer.length==0) return; - + if (buffer.length == 0) { + return; + } + int bytesRead = 0; int offset = 0; int length = buffer.length; @@ -468,7 +457,7 @@ public static void readFully(InputStream inputStream, byte[] buffer) throws IOEx offset += bytesRead; } } - if (offset==0) { + if (offset == 0) { throw new EOFException("end of stream detected"); } if (offset < length) { diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java index 3b4a68e..dd58239 100644 --- a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java @@ -8,6 +8,7 @@ import java.net.InetSocketAddress; import java.util.EnumSet; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; @@ -17,7 +18,7 @@ import com.sun.net.httpserver.Headers; import robaho.net.httpserver.NoSyncBufferedOutputStream; -import robaho.net.httpserver.ServerConfig; +import robaho.net.httpserver.OptimizedHeaders; import robaho.net.httpserver.http2.hpack.HPackContext; import robaho.net.httpserver.http2.frame.BaseFrame; import robaho.net.httpserver.http2.frame.DataFrame; @@ -41,8 +42,8 @@ public class HTTP2Stream { private final Pipe pipe; private final HTTP2Connection.StreamHandler handler; private final Headers requestHeaders; - private final Headers responseHeaders = new Headers(); - private volatile boolean headersSent = false; + private final Headers responseHeaders = new OptimizedHeaders(16); + private final AtomicBoolean headersSent = new AtomicBoolean(false); private volatile Thread thread; private volatile boolean streamOpen = true; @@ -59,8 +60,10 @@ public HTTP2Stream(int streamId, HTTP2Connection connection, Headers requestHead this.pipe = new Pipe(); this.outputStream = new NoSyncBufferedOutputStream(new Http2OutputStream(streamId)); var setting = connection.getRemoteSettings().get(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE); - if(setting!=null) + if(setting!=null) { sendWindow.addAndGet((int)(setting.value-65535)); + } + logger.log(Level.TRACE,() -> "new stream, window size "+sendWindow.get()+" on stream "+streamId); } public OutputStream getOutputStream() { @@ -91,7 +94,7 @@ public void close() { streamOpen = false; halfClosed = true; - if(connection.http2Streams.remove(streamId)==null) { + if(connection.http2Streams.put(streamId,null)==null) { return; } @@ -102,6 +105,7 @@ public void close() { thread.interrupt(); } catch (IOException e) { if(!connection.isClosed()) { + connection.close(); logger.log(connection.httpConnection.requestCount.get()>0 ? Level.WARNING : Level.DEBUG,"IOException closing http2 stream",e); } } finally { @@ -186,15 +190,12 @@ private void performRequest(boolean halfClosed) throws IOException { }); } public void writeResponseHeaders() throws IOException { - if(headersSent) return; + if(!headersSent.compareAndSet(false,true)) + return; connection.lock(); try { - if (headersSent) { - return; - } - HPackContext.writeHeaderFrame(responseHeaders, connection.outputStream, streamId); - } finally { - headersSent = true; + HPackContext.writeHeaderFrame(responseHeaders,connection.outputStream,streamId); + } finally { connection.unlock(); } } @@ -207,8 +208,9 @@ public InetSocketAddress getRemoteAddress() { } class Http2OutputStream extends OutputStream { + private static final EnumSet END_STREAM = EnumSet.of(FrameFlag.END_STREAM); + private final int streamId; - private final OutputStream outputStream = connection.outputStream; private final int max_frame_size; private boolean closed; private long pauses = 0; @@ -236,74 +238,75 @@ public void write(byte[] b, int off, int len) throws IOException { pauses++; LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); } - connection.lock(); - try { - if (!headersSent) { - writeResponseHeaders(); - } - while(len>0) { - int _len = Math.min(Math.min(len,max_frame_size),(int)Math.min(connection.sendWindow.get(),sendWindow.get())); - if(_len<=0) { - try { - connection.unlock(); - pauses++; - LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(16)); - if(connection.isClosed()) { - throw new IOException("connection closed"); - } - continue; - } finally { - connection.lock(); - } + writeResponseHeaders(); + while(len>0) { + int _len = Math.min(Math.min(len,max_frame_size),(int)Math.min(connection.sendWindow.get(),sendWindow.get())); + if(_len<=0) { + pauses++; + connection.lock(); + try { + connection.outputStream.flush(); + } finally { + connection.unlock(); } - FrameHeader header = new FrameHeader(_len, FrameType.DATA, EnumSet.noneOf(FrameFlag.class), streamId); - logger.log(Level.TRACE,() -> "sending data frame length "+_len+" on stream "+streamId); - header.writeTo(outputStream); - outputStream.write(b, off, _len); - off+=_len; - len-=_len; - connection.sendWindow.addAndGet(-_len); - if(sendWindow.addAndGet(-_len)<=0) { - outputStream.flush(); + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + if(connection.isClosed()) { + throw new IOException("connection closed"); } + int remaining = len; + // logger.log(Level.TRACE,() -> "paused sending data frame, remaining "+remaining+", length "+_len+" on stream "+streamId); + continue; } - } finally { - connection.unlock(); + if(connection.sendWindow.addAndGet(-_len)<0) { + // if we can't get the space from the connection window, need to retry + connection.sendWindow.addAndGet(_len); + continue; + } + connection.lock(); + try { + FrameHeader.writeTo(connection.outputStream, _len, FrameType.DATA, FrameFlag.NONE, streamId); + connection.outputStream.write(b,off,_len); + } finally { + connection.unlock(); + } + // byte[] header = FrameHeader.encode(_len, FrameType.DATA, FrameFlag.NONE, streamId); + // byte[] data = Arrays.copyOfRange(b,off, len); + // connection.enqueue(List.of(header,data)); + off+=_len; + len-=_len; + sendWindow.addAndGet(-_len); + logger.log(Level.TRACE,() -> "sent data frame, length "+_len+", new send window "+sendWindow.get()+" on stream "+streamId); } } @Override public void flush() throws IOException { - connection.lock(); - try { - outputStream.flush(); - } finally { - connection.unlock(); - } } @Override public void close() throws IOException { if(closed) return; if(pauses>0) - logger.log(Level.TRACE,() -> "sending stream window exhausted "+pauses+" on stream "+streamId); - connection.lock(); + logger.log(Level.INFO,() -> "sending stream window exhausted "+pauses+" on stream "+streamId); try { if(connection.isClosed()) { - if(!headersSent) { + if(!headersSent.get()) { logger.log(Level.WARNING,"stream connection is closed and headers not sent on stream "+streamId); } return; } - if (!headersSent) { - writeResponseHeaders(); - } - FrameHeader header = new FrameHeader(0, FrameType.DATA, EnumSet.of(FrameFlag.END_STREAM), streamId); - header.writeTo(connection.outputStream); - if(ServerConfig.http2DisableFlushDelay() || connection.requestsInProgress.decrementAndGet()==0) { - connection.outputStream.flush(); + connection.requestsInProgress.decrementAndGet(); + writeResponseHeaders(); + connection.lock(); + try { + FrameHeader.writeTo(connection.outputStream, 0, FrameType.DATA, END_STREAM, streamId); + if(connection.requestsInProgress.get()<=0) { + connection.outputStream.flush(); + } + } finally { + connection.unlock(); } + // connection.enqueue(header); } finally { closed=true; - connection.unlock(); HTTP2Stream.this.close(); } } diff --git a/src/main/java/robaho/net/httpserver/http2/Utils.java b/src/main/java/robaho/net/httpserver/http2/Utils.java index 278e83a..7c38a44 100644 --- a/src/main/java/robaho/net/httpserver/http2/Utils.java +++ b/src/main/java/robaho/net/httpserver/http2/Utils.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.io.OutputStream; +import java.util.Deque; import java.util.List; public class Utils { @@ -47,8 +48,8 @@ public static void convertToBinary(byte[] buffer, int pos, int input) { } public static void convertToBinary(byte[] buffer, int pos, int input, int length) { - for (int i = pos; i < pos + length; i++) { - buffer[i] = (byte) ((input >> (8 * i)) & 255); + for (int i = 0; i < length; i++) { + buffer[i+pos] = (byte) ((input >> (8 * (length-1-i))) & 255); } } @@ -61,8 +62,10 @@ public static void writeBinary(OutputStream os,int input,int length) throws IOEx os.write((byte) ((input >> (8 * i)) & 0xFF)); } } + private static final byte[] EMPTY = new byte[0]; public static byte[] combineByteArrays(List blocks) { + if(blocks.isEmpty()) return EMPTY; if(blocks.size()==1) return blocks.get(0); int totalLength = 0; diff --git a/src/main/java/robaho/net/httpserver/http2/frame/BaseFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/BaseFrame.java index 217afb0..605d912 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/BaseFrame.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/BaseFrame.java @@ -1,5 +1,6 @@ package robaho.net.httpserver.http2.frame; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; @@ -22,4 +23,13 @@ public void setHeader(FrameHeader header) } public abstract void writeTo(OutputStream os) throws IOException; + + public byte[] encode() { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try { + writeTo(bos); + } catch (IOException ignore) { + } + return bos.toByteArray(); + } } diff --git a/src/main/java/robaho/net/httpserver/http2/frame/ContinuationFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/ContinuationFrame.java index 877f967..f8107bc 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/ContinuationFrame.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/ContinuationFrame.java @@ -2,8 +2,10 @@ import java.io.IOException; import java.io.OutputStream; +import java.util.List; import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.Utils; public class ContinuationFrame extends BaseFrame { private final byte[] body; @@ -24,5 +26,9 @@ public static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws HTTP2 public byte[] getHeaderBlock() { return body; } + + public byte[] encode() { + return Utils.combineByteArrays(List.of(getHeader().encode(),body)); + } } diff --git a/src/main/java/robaho/net/httpserver/http2/frame/DataFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/DataFrame.java index ab3fd0c..f3e7d04 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/DataFrame.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/DataFrame.java @@ -8,6 +8,7 @@ import robaho.net.httpserver.http2.HTTP2Exception; public class DataFrame extends BaseFrame { + public final byte[] body; public DataFrame(FrameHeader header,byte[] body) { super(header); @@ -32,4 +33,7 @@ public static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws HTTP2 return new DataFrame(frameHeader, body); } } + public byte[] encode() { + throw new UnsupportedOperationException(); + } } diff --git a/src/main/java/robaho/net/httpserver/http2/frame/FrameFlag.java b/src/main/java/robaho/net/httpserver/http2/frame/FrameFlag.java index 2eef22d..ac42899 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/FrameFlag.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/FrameFlag.java @@ -1,6 +1,8 @@ package robaho.net.httpserver.http2.frame; +import java.util.Collections; import java.util.EnumSet; +import java.util.Set; import robaho.net.httpserver.http2.HTTP2Exception; @@ -15,18 +17,24 @@ public enum FrameFlag { PADDED((byte)0x8), PRIORITY((byte)0x20); - - byte value; + private final byte value; FrameFlag(byte value) { this.value = value; } + private static final FrameFlag[] _values = FrameFlag.values(); + + public static final Set NONE = Collections.unmodifiableSet(EnumSet.noneOf(FrameFlag.class)); + public byte getValue() { return value; } - public static EnumSet getEnumSet(byte value, FrameType type) throws HTTP2Exception { + public static Set getEnumSet(byte value, FrameType type) throws HTTP2Exception { + if(value==0) { + return NONE; + } // Empty EnumSet EnumSet result = EnumSet.noneOf(FrameFlag.class); @@ -49,7 +57,7 @@ public static EnumSet getEnumSet(byte value, FrameType type) throws H } // For each flag in FrameFlag - for (FrameFlag flag : FrameFlag.values()) { + for (FrameFlag flag : _values) { // Check whether the flag bit is set if ((value & flag.value) != 0) { result.add(flag); @@ -67,7 +75,7 @@ public static EnumSet getEnumSet(byte value, FrameType type) throws H return result; } - public static byte getValue(EnumSet flags) { + public static byte getValue(Set flags) { byte result = 0; @@ -77,5 +85,4 @@ public static byte getValue(EnumSet flags) { return result; } - } diff --git a/src/main/java/robaho/net/httpserver/http2/frame/FrameHeader.java b/src/main/java/robaho/net/httpserver/http2/frame/FrameHeader.java index 4745b92..059771f 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/FrameHeader.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/FrameHeader.java @@ -2,7 +2,7 @@ import java.io.IOException; import java.io.OutputStream; -import java.util.EnumSet; +import java.util.Set; import robaho.net.httpserver.http2.Utils; @@ -13,7 +13,7 @@ public class FrameHeader { private final int length; private final FrameType type; - private final EnumSet flags; + private final Set flags; private final int streamIdentifier; /** @@ -36,7 +36,7 @@ public FrameType getType() { * defined as an EnumSet<FrameFlag>, it identifies flags associated with a * particular frame */ - public EnumSet getFlags() { + public Set getFlags() { return flags; } @@ -47,7 +47,7 @@ public int getStreamIdentifier() { return streamIdentifier; } - public FrameHeader(int length, FrameType type, EnumSet flags, int streamIdentifier) { + public FrameHeader(int length, FrameType type, Set flags, int streamIdentifier) { this.length = length; this.type = type; this.flags = flags; @@ -75,7 +75,7 @@ public static FrameHeader Parse(byte[] tmpBuffer) throws Exception { FrameHeader frameHeader = null; FrameType type = null; - EnumSet flag = null; + Set flag = null; int streamIdentifier = 0; int length = 0; int readIndex = 0; @@ -98,6 +98,15 @@ public static FrameHeader Parse(byte[] tmpBuffer) throws Exception { return frameHeader; } + + public static String debug(byte[] header) { + try { + var type = FrameType.getEnum(header[3]); + return "length="+Utils.convertToInt(header, 0, 3)+", type "+type+", flags "+FrameFlag.getEnumSet(header[4], type)+", stream "+Utils.convertToInt(header, 5); + } catch (Exception ex) { + return ""; + } + } public void writeTo(OutputStream os) throws IOException { Utils.writeBinary(os,this.length,3); @@ -105,4 +114,38 @@ public void writeTo(OutputStream os) throws IOException { os.write(FrameFlag.getValue(this.getFlags()) & 0xFF); Utils.writeBinary(os,this.streamIdentifier); } + + public static void writeTo(OutputStream os, int length,FrameType frameType,Set flags,int streamId) throws IOException { + Utils.writeBinary(os,length,3); + os.write(frameType.value & 0xFF); + os.write(FrameFlag.getValue(flags) & 0xFF); + Utils.writeBinary(os,streamId); + } + + public static byte[] encode(int length,FrameType frameType,Set flags,int streamId) { + byte[] buffer = new byte[9]; + Utils.convertToBinary(buffer, 0, length, 3); + buffer[3] = (byte)(frameType.value & 0xFF); + buffer[4] = (byte)(FrameFlag.getValue(flags) & 0xFF); + Utils.convertToBinary(buffer, 5, streamId,4); + return buffer; + } + + public byte[] encode() { + byte[] buffer = new byte[9]; + Utils.convertToBinary(buffer, 0, length ,3); + buffer[3] = (byte)(type.value & 0xFF); + buffer[4] = (byte)(FrameFlag.getValue(flags) & 0xFF); + Utils.convertToBinary(buffer, 5, streamIdentifier,4); + return buffer; + } + + /** encode into an existing byte array */ + public byte[] encode(byte[] buffer) { + Utils.convertToBinary(buffer, 0, length ,3); + buffer[3] = (byte)(type.value & 0xFF); + buffer[4] = (byte)(FrameFlag.getValue(flags) & 0xFF); + Utils.convertToBinary(buffer, 5, streamIdentifier,4); + return buffer; + } } diff --git a/src/main/java/robaho/net/httpserver/http2/frame/FrameSerializer.java b/src/main/java/robaho/net/httpserver/http2/frame/FrameSerializer.java index 17c8a6d..270d07e 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/FrameSerializer.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/FrameSerializer.java @@ -2,6 +2,7 @@ import java.io.InputStream; +import robaho.net.httpserver.ServerConfig; import robaho.net.httpserver.http2.HTTP2Connection; import robaho.net.httpserver.http2.HTTP2ErrorCode; import robaho.net.httpserver.http2.HTTP2Exception; @@ -10,19 +11,19 @@ public class FrameSerializer { public static BaseFrame deserialize(InputStream inputStream) throws Exception { - BaseFrame baseFrame = null; - byte[] tmpBuffer = new byte[9]; + BaseFrame baseFrame; + byte[] tmpBuffer = new byte[9]; HTTP2Connection.readFully(inputStream, tmpBuffer); FrameHeader frameHeader = FrameHeader.Parse(tmpBuffer); - byte[] body = new byte[frameHeader.getLength()]; - HTTP2Connection.readFully(inputStream, body); - - if(frameHeader.getLength() > 16384) { + if(frameHeader.getLength() > ServerConfig.http2MaxFrameSize()) { throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); } + byte[] body = new byte[frameHeader.getLength()]; + HTTP2Connection.readFully(inputStream, body); + switch (frameHeader.getType()) { case HEADERS: baseFrame = HeadersFrame.parse(body, frameHeader); diff --git a/src/main/java/robaho/net/httpserver/http2/frame/FrameType.java b/src/main/java/robaho/net/httpserver/http2/frame/FrameType.java index a124665..170a403 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/FrameType.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/FrameType.java @@ -9,11 +9,14 @@ */ public enum FrameType { - DATA((byte) 0x0), HEADERS((byte) 0x1), PRIORITY((byte) 0x2), RST_STREAM((byte) 0x3), SETTINGS( - (byte) 0x4), PUSH_PROMISE((byte) 0x5), PING((byte) 0x6), GOAWAY( - (byte) 0x7), WINDOW_UPDATE((byte) 0x8), CONTINUATION((byte) 0x9), NOT_IMPLEMENTED((byte) 0xA); + DATA((byte) 0x0), HEADERS((byte) 0x1), PRIORITY((byte) 0x2), RST_STREAM((byte) 0x3), + SETTINGS((byte) 0x4), PUSH_PROMISE((byte) 0x5), PING((byte) 0x6), + GOAWAY((byte) 0x7), WINDOW_UPDATE((byte) 0x8), CONTINUATION((byte) 0x9), + NOT_IMPLEMENTED((byte) 0xA); - byte value; + final byte value; + + private static final FrameType[] _values = FrameType.values(); FrameType(byte value) { this.value = value; @@ -24,10 +27,9 @@ public byte getValue() { } public static FrameType getEnum(int value) { - for (FrameType e : FrameType.values()) { - if (e.getValue() == value) - return e; - } - return FrameType.NOT_IMPLEMENTED; + if(value < 0 || value > 0x9) { + return NOT_IMPLEMENTED; + } + return _values[value]; } } diff --git a/src/main/java/robaho/net/httpserver/http2/frame/GoawayFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/GoawayFrame.java index b63fcd6..c951465 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/GoawayFrame.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/GoawayFrame.java @@ -9,15 +9,27 @@ public class GoawayFrame extends BaseFrame { public final HTTP2ErrorCode errorCode; + public final int lastSeenStream; public GoawayFrame(FrameHeader header,HTTP2ErrorCode errorCode) { + this(header,errorCode,0); + } + + public GoawayFrame(FrameHeader header,HTTP2ErrorCode errorCode,int lastSeenStream) { super(header); this.errorCode = errorCode; + this.lastSeenStream = lastSeenStream; + } + + public GoawayFrame(HTTP2ErrorCode errorCode,int lastSeenStream) { + this(new FrameHeader(8,FrameType.GOAWAY,FrameFlag.NONE,0),errorCode,lastSeenStream); } @Override public void writeTo(OutputStream os) throws IOException { getHeader().writeTo(os); + Utils.writeBinary(os, lastSeenStream); + Utils.writeBinary(os, errorCode.getValue()); } public static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws HTTP2Exception { @@ -28,7 +40,7 @@ public static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws HTTP2 throw new HTTP2Exception(HTTP2ErrorCode.FRAME_SIZE_ERROR); } try { - var errorCode = Utils.convertToInt(body, 4); + var errorCode = Utils.convertToInt(body, 4, 4); return new GoawayFrame(frameHeader,HTTP2ErrorCode.getEnum(errorCode)); } catch (Exception e) { throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); diff --git a/src/main/java/robaho/net/httpserver/http2/frame/HeadersFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/HeadersFrame.java index b7ffad0..14057f5 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/HeadersFrame.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/HeadersFrame.java @@ -25,7 +25,7 @@ public class HeadersFrame extends BaseFrame { private byte[] padding; public HeadersFrame() { - this(new FrameHeader(0, FrameType.HEADERS, EnumSet.noneOf(FrameFlag.class), 0)); + this(new FrameHeader(0, FrameType.HEADERS, FrameFlag.NONE, 0)); } public HeadersFrame(FrameHeader header) { @@ -164,9 +164,12 @@ public static HeadersFrame parse(byte[] frameBody, FrameHeader header) throws HT @Override public void writeTo(OutputStream os) throws IOException { - FrameHeader header = new FrameHeader(getHeaderBlock().length, FrameType.HEADERS, EnumSet.of(FrameFlag.END_HEADERS), getHeader().getStreamIdentifier()); - header.writeTo(os); - os.write(getHeaderBlock()); + byte[] buffer = getHeaderBlock(); + FrameHeader.writeTo(os, buffer.length, FrameType.HEADERS, EnumSet.of(FrameFlag.END_HEADERS), getHeader().getStreamIdentifier()); + os.write(buffer); os.flush(); } + public byte[] encode() { + throw new UnsupportedOperationException("use HPackContext encodeFrameHeaders()"); + } } diff --git a/src/main/java/robaho/net/httpserver/http2/frame/PingFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/PingFrame.java index bbbc8c0..13610a8 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/PingFrame.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/PingFrame.java @@ -2,9 +2,12 @@ import java.io.IOException; import java.io.OutputStream; +import java.util.EnumSet; +import java.util.List; import robaho.net.httpserver.http2.HTTP2ErrorCode; import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.Utils; public class PingFrame extends BaseFrame { public final byte[] body; @@ -13,6 +16,14 @@ public PingFrame(FrameHeader header, byte[] body) { super(header); this.body = body; } + public PingFrame() { + super(new FrameHeader(8,FrameType.PING,FrameFlag.NONE,0)); + body = new byte[8]; + } + public PingFrame(PingFrame toBeAcked) { + super(new FrameHeader(toBeAcked.body.length,FrameType.PING,EnumSet.of(FrameFlag.ACK),0)); + body = toBeAcked.body; + } @Override public void writeTo(OutputStream os) throws IOException { @@ -28,4 +39,7 @@ public static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws HTTP2 } return new PingFrame(frameHeader, body); } + public byte[] encode() { + return Utils.combineByteArrays(List.of(getHeader().encode(),body)); + } } diff --git a/src/main/java/robaho/net/httpserver/http2/frame/PriorityFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/PriorityFrame.java index 1fead75..5dfe343 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/PriorityFrame.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/PriorityFrame.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.io.OutputStream; +import java.util.List; import robaho.net.httpserver.http2.HTTP2ErrorCode; import robaho.net.httpserver.http2.HTTP2Exception; @@ -36,4 +37,10 @@ static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws Exception { return frame; } + public byte[] encode() { + byte[] buffer = new byte[5]; + Utils.convertToBinary(buffer, 0, streamDependency); + buffer[4] = (byte)((weight-1) & 0xFF); + return Utils.combineByteArrays(List.of(getHeader().encode(),buffer)); + } } diff --git a/src/main/java/robaho/net/httpserver/http2/frame/ResetStreamFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/ResetStreamFrame.java index 1c7652e..15f6289 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/ResetStreamFrame.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/ResetStreamFrame.java @@ -2,6 +2,8 @@ import java.io.IOException; import java.io.OutputStream; +import java.util.EnumSet; +import java.util.List; import robaho.net.httpserver.http2.HTTP2ErrorCode; import robaho.net.httpserver.http2.HTTP2Exception; @@ -15,6 +17,10 @@ public ResetStreamFrame(FrameHeader header,HTTP2ErrorCode errorCode) { super(header); this.errorCode = errorCode; } + public ResetStreamFrame(HTTP2ErrorCode errorCode,int streamId) { + super(new FrameHeader(4,FrameType.RST_STREAM,FrameFlag.NONE,streamId)); + this.errorCode = errorCode; + } @Override public void writeTo(OutputStream os) throws IOException { @@ -29,4 +35,10 @@ static BaseFrame parse(byte[] body, FrameHeader frameHeader) throws HTTP2Excepti } return new ResetStreamFrame(frameHeader,HTTP2ErrorCode.getEnum(convertToInt(body, 0))); } + public byte[] encode() { + byte[] buffer = new byte[4]; + Utils.convertToBinary(buffer, 0, errorCode.getValue()); + return Utils.combineByteArrays(List.of(getHeader().encode(),buffer)); + } + } diff --git a/src/main/java/robaho/net/httpserver/http2/frame/SettingIdentifier.java b/src/main/java/robaho/net/httpserver/http2/frame/SettingIdentifier.java index a8a47ef..8cf03f7 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/SettingIdentifier.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/SettingIdentifier.java @@ -19,10 +19,12 @@ public int getValue() { return value; } + static final SettingIdentifier[] _values = SettingIdentifier.values(); + public static SettingIdentifier getEnum(int value) { SettingIdentifier result = SettingIdentifier.SETTINGS_NONE; - for (SettingIdentifier e : SettingIdentifier.values()) { + for (SettingIdentifier e : _values) { if (e.getValue() == value) result = e; } diff --git a/src/main/java/robaho/net/httpserver/http2/frame/SettingsFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/SettingsFrame.java index 87b3ab6..2baa120 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/SettingsFrame.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/SettingsFrame.java @@ -1,5 +1,6 @@ package robaho.net.httpserver.http2.frame; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.util.ArrayList; @@ -69,4 +70,13 @@ public void writeTo(OutputStream os) throws IOException { } } + public byte[] encode() { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + try { + writeTo(bos); + } catch (IOException ignore) { + } + return bos.toByteArray(); + } + } diff --git a/src/main/java/robaho/net/httpserver/http2/frame/SettingsMap.java b/src/main/java/robaho/net/httpserver/http2/frame/SettingsMap.java new file mode 100644 index 0000000..6044315 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/frame/SettingsMap.java @@ -0,0 +1,25 @@ +package robaho.net.httpserver.http2.frame; + +import java.util.function.Consumer; + +public class SettingsMap { + private final SettingParameter[] settings = new SettingParameter[SettingIdentifier._values.length]; + + public SettingParameter get(SettingIdentifier identifier) { + return settings[identifier.getValue()]; + } + public SettingParameter getOrDefault(SettingIdentifier identifier,SettingParameter defaultValue) { + SettingParameter setting = settings[identifier.getValue()]; + return setting == null ? defaultValue : setting; + } + public void set(SettingParameter setting) { + settings[setting.identifier.getValue()] = setting; + } + public void forEach(Consumer consumer) { + for (SettingParameter setting : settings) { + if (setting != null) { + consumer.accept(setting); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/http2/frame/WindowUpdateFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/WindowUpdateFrame.java index 16c422c..31fa9e5 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/WindowUpdateFrame.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/WindowUpdateFrame.java @@ -14,6 +14,10 @@ public class WindowUpdateFrame extends BaseFrame { public WindowUpdateFrame(FrameHeader header) { super(header); } + public WindowUpdateFrame(int streamId,int increment) { + super(new FrameHeader(4,FrameType.WINDOW_UPDATE,FrameFlag.NONE,streamId)); + windowSizeIncrement = increment; + } public int getWindowSizeIncrement() { @@ -41,5 +45,14 @@ public static WindowUpdateFrame parse(byte[] frameBody, FrameHeader header) thro @Override public void writeTo(OutputStream os) throws IOException { getHeader().writeTo(os); + Utils.writeBinary(os, windowSizeIncrement); + } + + @Override + public byte[] encode() { + byte[] buffer = new byte[9+4]; + getHeader().encode(buffer); + Utils.convertToBinary(buffer,9,windowSizeIncrement); + return buffer; } } diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java b/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java index e5aec68..9b6125d 100644 --- a/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java +++ b/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java @@ -2,9 +2,13 @@ import java.io.IOException; import java.io.OutputStream; +import java.lang.reflect.Array; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; +import java.util.Deque; import java.util.EnumSet; +import java.util.LinkedList; import robaho.net.httpserver.http2.HTTP2ErrorCode; import robaho.net.httpserver.http2.HTTP2Exception; @@ -18,6 +22,7 @@ import robaho.net.httpserver.http2.frame.FrameHeader; import robaho.net.httpserver.http2.frame.FrameType; import robaho.net.httpserver.http2.Utils; +import robaho.net.httpserver.http2.frame.FrameType; public class HPackContext { private final List dynamicTable = new ArrayList(1024); @@ -86,7 +91,7 @@ private int decodeIndexedHeaderField(byte[] buffer, int index, HTTP2HeaderField throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR, "Invalid header index " + headerIndex + ", dynamic: "+dynamicTable); } - headerField.setName(field.name); + headerField.setName(field.name,field.normalizedName); headerField.setValue(field.value); return index; @@ -151,7 +156,7 @@ private int decodeFieldName(byte[] buffer, int index, int headerIndex, HTTP2Head if (field == null) { throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR, "Invalid header index " + headerIndex); } - headerField.setName(field.name); + headerField.setName(field.name,field.normalizedName); } return index; } @@ -208,27 +213,34 @@ private int decodeLiteralFieldNeverIndexed(byte[] buffer, int index, HTTP2Header } public static void writeHeaderFrame(Headers headers, OutputStream outputStream, int streamId) throws IOException { - byte[] buffer = encodeHeadersFrame(headers); - FrameHeader header = new FrameHeader(buffer.length, FrameType.HEADERS, EnumSet.of(FrameFlag.END_HEADERS), streamId); - header.writeTo(outputStream); + byte[] buffer = encodeHeaders(headers); + FrameHeader.writeTo(outputStream, buffer.length,FrameType.HEADERS, END_OF_HEADERS, streamId); outputStream.write(buffer); - // System.out.println("HPACK.writeHeaderFrame: wrote header frame, length: " + buffer.length + ", streamId: " + streamId); } - private static byte[] encodeHeadersFrame(Headers headers) { - List fields = new ArrayList(); - for (String name : headers.keySet()) { - for (String value : headers.get(name)) { + private static final EnumSet END_OF_HEADERS = EnumSet.of(FrameFlag.END_HEADERS); + + public static List encodeHeadersFrame(Headers headers,int streamId) { + byte[] buffer = encodeHeaders(headers); + byte[] header = FrameHeader.encode(buffer.length, FrameType.HEADERS, END_OF_HEADERS, streamId); + return List.of(header,buffer); + } + + private static byte[] encodeHeaders(Headers headers) { + List fields = new ArrayList(headers.size()); + List pseudo = new ArrayList<>(6); + headers.forEach((name, values) -> { + for (String value : values) { byte[] header = encodeHeader(name.toLowerCase(), value); if(name.startsWith(":")) { - fields.add(0, header); + pseudo.add(header); } else { fields.add(header); } } - } - return Utils.combineByteArrays(fields); + }); + return Utils.combineByteArrays(List.of(Utils.combineByteArrays(pseudo),Utils.combineByteArrays(fields))); } private static byte[] encodeHeader(String name, String value) { diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/HTTP2HeaderField.java b/src/main/java/robaho/net/httpserver/http2/hpack/HTTP2HeaderField.java index 1d3cc7d..4e01d42 100644 --- a/src/main/java/robaho/net/httpserver/http2/hpack/HTTP2HeaderField.java +++ b/src/main/java/robaho/net/httpserver/http2/hpack/HTTP2HeaderField.java @@ -4,6 +4,7 @@ public class HTTP2HeaderField { public String name; public String value; + public String normalizedName; public HTTP2HeaderField() { } @@ -11,6 +12,7 @@ public HTTP2HeaderField() { public HTTP2HeaderField(String name, String value) { this.name = name; this.value = value; + this.normalizedName = normalize(name); } public String getName() { @@ -19,6 +21,11 @@ public String getName() { public void setName(String name) { this.name = name; + this.normalizedName = normalize(name); + } + public void setName(String name,String normalizedName) { + this.name = name; + this.normalizedName = normalizedName; } public String getValue() { @@ -38,4 +45,10 @@ public boolean isPseudoHeader() { return name.startsWith(":"); } + public static String normalize(String value) { + if (value == null || value.isEmpty()) { + return value; + } + return Character.toUpperCase(value.charAt(0)) + value.substring(1); + } } diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/HeaderFields.java b/src/main/java/robaho/net/httpserver/http2/hpack/HeaderFields.java index 92c6c09..58ccaed 100644 --- a/src/main/java/robaho/net/httpserver/http2/hpack/HeaderFields.java +++ b/src/main/java/robaho/net/httpserver/http2/hpack/HeaderFields.java @@ -1,12 +1,11 @@ package robaho.net.httpserver.http2.hpack; import java.util.ArrayList; -import java.util.HashMap; import java.util.Iterator; import java.util.List; -import java.util.Map; import java.util.Set; +import robaho.net.httpserver.OpenAddressMap; import robaho.net.httpserver.http2.HTTP2ErrorCode; import robaho.net.httpserver.http2.HTTP2Exception; @@ -21,7 +20,7 @@ public class HeaderFields implements Iterable { private static final Set pseudoHeadersIn = Set.of(":authority", ":method", ":path", ":scheme"); private final List fields = new ArrayList(); - private final Map pseudoHeaders = new HashMap(); + private final OpenAddressMap pseudoHeaders = new OpenAddressMap(8); private boolean hasNonPseudoHeader = false; @@ -42,7 +41,7 @@ public void addHeaderField(HTTP2HeaderField field) throws HTTP2Exception{ hasNonPseudoHeader = true; } if(field.isPseudoHeader() && hasNonPseudoHeader) { - throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"Pseudo-header fields must appear before regular header fields"); + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"pseudo-header fields must appear before regular header fields"); } if(field.isPseudoHeader()) { if(pseudoHeaders.put(field.getName(),field)!=null && requiredHeaderFields.contains(field.getName())) { @@ -73,9 +72,12 @@ public void clear() { public void validate() throws HTTP2Exception { for(var fieldName : requiredHeaderFields) { var ph = pseudoHeaders.get(fieldName); - if(ph==null || isEmpty(ph.getValue())) { + if(ph==null || isEmpty(((HTTP2HeaderField)ph).getValue())) { throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"missing required header field "+fieldName); } } } + public int size() { + return fields.size(); + } } \ No newline at end of file diff --git a/src/test/extras/SimpleFileServer.java b/src/test/extras/SimpleFileServer.java index 5459628..5ae3d8a 100644 --- a/src/test/extras/SimpleFileServer.java +++ b/src/test/extras/SimpleFileServer.java @@ -58,6 +58,8 @@ public static void main(String[] args) throws Exception { ch.setLevel(Level.ALL); logger.addHandler(ch); + logger.log(Level.INFO,() -> "using Java version: " + System.getProperty("java.version")); + String rootDir = args[0]; int port = Integer.parseInt(args[1]); String logfile = args[2]; diff --git a/src/test/java/FileServerHandler.java b/src/test/java/FileServerHandler.java index 7abe93e..c52f541 100644 --- a/src/test/java/FileServerHandler.java +++ b/src/test/java/FileServerHandler.java @@ -77,7 +77,7 @@ public void handle (HttpExchange t) String method = t.getRequestMethod(); if (method.equals ("HEAD")) { - rmap.set ("Content-Length", Long.toString (f.length())); + rmap.set ("Content-length", Long.toString (f.length())); t.sendResponseHeaders (200, -1); t.close(); } else if (!method.equals("GET")) { @@ -87,9 +87,9 @@ public void handle (HttpExchange t) } if (path.endsWith (".html") || path.endsWith (".htm")) { - rmap.set ("Content-Type", "text/html"); + rmap.set ("Content-type", "text/html"); } else { - rmap.set ("Content-Type", "text/plain"); + rmap.set ("Content-type", "text/plain"); } if (f.isDirectory()) { if (!path.endsWith ("/")) { From cbcd51816801f44e6c56d0fa6919fea1abab19c2 Mon Sep 17 00:00:00 2001 From: robert engels Date: Thu, 2 Jan 2025 13:05:27 -0600 Subject: [PATCH 17/78] version 1.0.14 --- README.md | 6 +++++- build.gradle | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1c7615e..592d740 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,9 @@ The frameworks were also tested using [go-wrk](https://github.com/tsliwowicz/go- 1_The robaho version has been submitted to the Tech Empower benchmarks project for 3-party confirmation._
2_`go-wrk` does not use http pipelining so, the large number of connections is the limiting factor._ +
+ performance details + **robaho tech empower** ``` robertengels@macmini go-wrk % wrk -H 'Host: imac' -H 'Accept: text/plain,text/html;q=0.9,application/xhtml+xml;q=0.9,application/xml;q=0.8,*/*;q=0.7' -H 'Connection: keep-alive' --latency -d 60 -c 64 --timeout 8 -t 2 http://imac:8080/plaintext -s ~/pipeline.lua -- 16 @@ -136,6 +139,7 @@ Number of Errors: 0 stddev: 174.373ms ``` +
## server statistics @@ -164,7 +168,7 @@ The counts can be reset using `/__stats?reset`. The `requests/sec` is calculated io.github.robaho httpserver - 1.0.13 + 1.0.14 ``` ## enable Http2 diff --git a/build.gradle b/build.gradle index 47f7045..e70a56e 100644 --- a/build.gradle +++ b/build.gradle @@ -156,7 +156,7 @@ publishing { maven(MavenPublication) { groupId = 'io.github.robaho' artifactId = 'httpserver' - version = "1.0.13" + version = "1.0.14" from components.java From 28308a3685e6a52e2f552e5909c1d54d213bb860 Mon Sep 17 00:00:00 2001 From: robert engels Date: Thu, 2 Jan 2025 14:25:48 -0600 Subject: [PATCH 18/78] update performance timings --- README.md | 159 ++++++++++++++++++++++++++++-------------------------- 1 file changed, 83 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 592d740..39011c9 100644 --- a/README.md +++ b/README.md @@ -51,8 +51,12 @@ The frameworks were also tested using [go-wrk](https://github.com/tsliwowicz/go- 1_The robaho version has been submitted to the Tech Empower benchmarks project for 3-party confirmation._
2_`go-wrk` does not use http pipelining so, the large number of connections is the limiting factor._ +Performance tests against the latest Jetty version were run. The `robaho httpserver` outperformed the Jetty http2 by 2x, and the Jettty http1 by 5x. + +The Javalin/Jetty project is available [here](https://github.com/robaho/javalin-http2-example) +
- performance details + vs JDK performance details **robaho tech empower** ``` @@ -140,78 +144,30 @@ stddev: 174.373ms ```
- -## server statistics - -The server tracks some basic statistics. To enable the access endpoint `/__stats`, set the system property `robaho.net.httpserver.EnableStatistics=true`. - -Sample usage: - -```shell -$ curl http://localhost:8080/__stats -Connections: 4264 -Active Connections: 2049 -Requests: 2669256 -Requests/sec: 73719 -Handler Exceptions: 0 -Socket Exceptions: 0 -Mac Connections Exceeded: 0 -Idle Closes: 0 -Reply Errors: 0 -``` - -The counts can be reset using `/__stats?reset`. The `requests/sec` is calculated from the previous statistics request. - -## maven - -```xml - - io.github.robaho - httpserver - 1.0.14 - -``` -## enable Http2 - -Http2 support is enabled via Java system properties. - -Use `-Drobaho.net.httpserver.http2OverSSL=true` to enable Http2 only via SSL connections. - -Use `-Drobaho.net.httpserver.http2OverNonSSL=true` to enable Http2 on Non-SSL connections (which requires prior knowledge). The Http2 upgrade mechanism was deprecated in RFC 9113 so it is not supported. - -See the additional Http2 options in `ServerConfig.java` - -The http2 implementation passes all specification tests in [h2spec](https://github.com/summerwind/h2spec) - -## Http2 performance - -Http2 performance has not yet been optimized, but an unscientific test shows the http2 implementation to have greater than 2x better throughput than the Javalin/Jetty 11 version. - -Still, the http2 version is almost 3x slower than the http1 version. I expect this to be the case with most http2 implementations. - -The Javalin/Jetty project is available [here](https://github.com/robaho/javalin-http2-example) -
- performance details + vs Jetty performance details + +The server is an iMac 4ghz quad-core i7 running OSX 13.7.2. JVM used is JDK 23.0.1. The `h2load` client was connected via a 20Gbs lightening network from an M1 Mac Mini. -All tests were run on the same hardware with the same JDK23 version. +Using `h2load -n 1000000 -m 1000 -c 16 [--h1] http://imac:` -Using `h2load -n 1000000 -m 1000 -c 16 [--h1] http://localhost:` +Jetty jetty-11.0.24 +Javalin version 6.4.0 Jetty 11 http2 ``` starting benchmark... spawning thread #0: 16 total client(s). 1000000 total requests Application protocol: h2c -finished in 5.25s, 190298.69 req/s, 6.72MB/s +finished in 5.20s, 192421.22 req/s, 6.79MB/s requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout status codes: 1000000 2xx, 0 3xx, 0 4xx, 0 5xx traffic: 35.29MB (37003264) total, 7.63MB (8002384) headers (space savings 90.12%), 10.49MB (11000000) data min max mean sd +/- sd -time for request: 160us 52.24ms 7.76ms 3.94ms 67.73% -time for connect: 235us 8.82ms 4.73ms 2.68ms 62.50% -time to 1st byte: 11.16ms 33.62ms 20.95ms 9.28ms 50.00% -req/s : 11894.25 12051.63 11957.08 58.94 56.25% +time for request: 142us 43.73ms 7.20ms 3.96ms 70.90% +time for connect: 176us 7.70ms 3.96ms 2.34ms 62.50% +time to 1st byte: 10.48ms 20.63ms 13.65ms 2.93ms 75.00% +req/s : 12026.57 12200.62 12070.81 46.69 93.75% ``` Jetty 11 http1 @@ -219,15 +175,15 @@ Jetty 11 http1 starting benchmark... spawning thread #0: 16 total client(s). 1000000 total requests Application protocol: http/1.1 -finished in 3.67s, 272138.02 req/s, 35.56MB/s +finished in 3.86s, 258839.63 req/s, 33.82MB/s requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout status codes: 1000000 2xx, 0 3xx, 0 4xx, 0 5xx traffic: 130.65MB (137000000) total, 86.78MB (91000000) headers (space savings 0.00%), 10.49MB (11000000) data min max mean sd +/- sd -time for request: 831us 189.78ms 57.30ms 21.98ms 71.20% -time for connect: 152us 4.21ms 2.19ms 1.24ms 62.50% -time to 1st byte: 4.85ms 11.73ms 7.11ms 2.29ms 81.25% -req/s : 17010.42 17843.23 17334.96 260.43 50.00% +time for request: 1.52ms 194.72ms 60.42ms 21.40ms 74.16% +time for connect: 172us 4.07ms 2.13ms 1.21ms 62.50% +time to 1st byte: 4.70ms 10.80ms 6.66ms 1.96ms 87.50% +req/s : 16178.98 16976.90 16456.91 175.54 81.25% ``` robaho http2 @@ -235,15 +191,15 @@ robaho http2 starting benchmark... spawning thread #0: 16 total client(s). 1000000 total requests Application protocol: h2c -finished in 2.20s, 453632.21 req/s, 19.04MB/s +finished in 2.23s, 447442.51 req/s, 18.78MB/s requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout status codes: 1000000 2xx, 0 3xx, 0 4xx, 0 5xx traffic: 41.96MB (44000480) total, 5.72MB (6000000) headers (space savings 76.92%), 10.49MB (11000000) data min max mean sd +/- sd -time for request: 347us 51.17ms 16.98ms 10.52ms 59.21% -time for connect: 228us 8.77ms 4.02ms 2.44ms 62.50% -time to 1st byte: 9.46ms 22.61ms 12.61ms 4.81ms 81.25% -req/s : 28353.29 29288.55 28542.35 229.27 87.50% +time for request: 472us 58.44ms 15.98ms 11.02ms 57.96% +time for connect: 169us 8.97ms 4.02ms 2.65ms 68.75% +time to 1st byte: 9.62ms 22.92ms 12.11ms 4.22ms 87.50% +req/s : 27969.77 28457.89 28079.91 147.96 81.25% ``` robaho http1 @@ -251,15 +207,66 @@ robaho http1 starting benchmark... spawning thread #0: 16 total client(s). 1000000 total requests Application protocol: http/1.1 -finished in 802.36ms, 1246317.13 req/s, 103.41MB/s +finished in 784.26ms, 1275080.84 req/s, 105.79MB/s requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout -status codes: 1001066 2xx, 0 3xx, 0 4xx, 0 5xx +status codes: 1001125 2xx, 0 3xx, 0 4xx, 0 5xx traffic: 82.97MB (87000000) total, 46.73MB (49000000) headers (space savings 0.00%), 10.49MB (11000000) data min max mean sd +/- sd -time for request: 860us 35.46ms 12.61ms 3.33ms 75.21% -time for connect: 92us 4.06ms 2.06ms 1.21ms 62.50% -time to 1st byte: 4.68ms 18.67ms 10.85ms 4.88ms 50.00% -req/s : 77913.01 80438.10 78458.60 721.68 81.25% +time for request: 763us 26.87ms 12.34ms 2.71ms 74.28% +time for connect: 104us 4.32ms 2.23ms 1.30ms 62.50% +time to 1st byte: 4.91ms 16.21ms 10.36ms 4.49ms 43.75% +req/s : 79744.46 81149.46 80228.21 355.56 75.00% ```
+ + +## server statistics + +The server tracks some basic statistics. To enable the access endpoint `/__stats`, set the system property `robaho.net.httpserver.EnableStatistics=true`. + +Sample usage: + +```shell +$ curl http://localhost:8080/__stats +Connections: 4264 +Active Connections: 2049 +Requests: 2669256 +Requests/sec: 73719 +Handler Exceptions: 0 +Socket Exceptions: 0 +Mac Connections Exceeded: 0 +Idle Closes: 0 +Reply Errors: 0 +``` + +The counts can be reset using `/__stats?reset`. The `requests/sec` is calculated from the previous statistics request. + +## maven + +```xml + + io.github.robaho + httpserver + 1.0.14 + +``` +## enable Http2 + +Http2 support is enabled via Java system properties. + +Use `-Drobaho.net.httpserver.http2OverSSL=true` to enable Http2 only via SSL connections. + +Use `-Drobaho.net.httpserver.http2OverNonSSL=true` to enable Http2 on Non-SSL connections (which requires prior knowledge). The Http2 upgrade mechanism was deprecated in RFC 9113 so it is not supported. + +See the additional Http2 options in `ServerConfig.java` + +The http2 implementation passes all specification tests in [h2spec](https://github.com/summerwind/h2spec) + +## Http2 performance notes + +Http2 performance has not fully optimized - there is room for improvement. + +The http2 version is almost 3x slower than the http1 version. I expect this to be the case with most http2 implementations due to the complexity. + + From c86cd6547bab6885b935e14d8036bb9b015ea232 Mon Sep 17 00:00:00 2001 From: robert engels Date: Thu, 2 Jan 2025 14:30:17 -0600 Subject: [PATCH 19/78] update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 39011c9..83bb457 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ The http2 implementation passes all specification tests in [h2spec](https://gith ## Http2 performance notes -Http2 performance has not fully optimized - there is room for improvement. +Http2 performance has not been fully optimized - there is room for improvement. The http2 version is almost 3x slower than the http1 version. I expect this to be the case with most http2 implementations due to the complexity. From 3c6c449c1e301899fe911fd57f3320cb8038d65e Mon Sep 17 00:00:00 2001 From: robert engels Date: Thu, 2 Jan 2025 14:40:56 -0600 Subject: [PATCH 20/78] http2 optimizations. update timings. --- README.md | 14 +++++++------- .../robaho/net/httpserver/OpenAddressIntMap.java | 2 ++ .../java/robaho/net/httpserver/OpenAddressMap.java | 10 ++++++++++ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 83bb457..2a39cf1 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ The frameworks were also tested using [go-wrk](https://github.com/tsliwowicz/go- 1_The robaho version has been submitted to the Tech Empower benchmarks project for 3-party confirmation._
2_`go-wrk` does not use http pipelining so, the large number of connections is the limiting factor._ -Performance tests against the latest Jetty version were run. The `robaho httpserver` outperformed the Jetty http2 by 2x, and the Jettty http1 by 5x. +Performance tests against the latest Jetty version were run. The `robaho httpserver` outperformed the Jetty http2 by 5x in both http1 and http2. The Javalin/Jetty project is available [here](https://github.com/robaho/javalin-http2-example) @@ -191,15 +191,15 @@ robaho http2 starting benchmark... spawning thread #0: 16 total client(s). 1000000 total requests Application protocol: h2c -finished in 2.23s, 447442.51 req/s, 18.78MB/s +finished in 1.08s, 927732.43 req/s, 38.93MB/s requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout status codes: 1000000 2xx, 0 3xx, 0 4xx, 0 5xx traffic: 41.96MB (44000480) total, 5.72MB (6000000) headers (space savings 76.92%), 10.49MB (11000000) data min max mean sd +/- sd -time for request: 472us 58.44ms 15.98ms 11.02ms 57.96% -time for connect: 169us 8.97ms 4.02ms 2.65ms 68.75% -time to 1st byte: 9.62ms 22.92ms 12.11ms 4.22ms 87.50% -req/s : 27969.77 28457.89 28079.91 147.96 81.25% +time for request: 226us 84.23ms 15.51ms 9.23ms 77.11% +time for connect: 521us 5.57ms 3.13ms 1.57ms 62.50% +time to 1st byte: 6.46ms 17.15ms 10.12ms 3.82ms 81.25% +req/s : 58012.46 66943.10 60509.05 2819.65 87.50% ``` robaho http1 @@ -267,6 +267,6 @@ The http2 implementation passes all specification tests in [h2spec](https://gith Http2 performance has not been fully optimized - there is room for improvement. -The http2 version is almost 3x slower than the http1 version. I expect this to be the case with most http2 implementations due to the complexity. +The http2 version is about 20% slower than http1. I expect this to be the case with most http2 implementations due to the complexity. diff --git a/src/main/java/robaho/net/httpserver/OpenAddressIntMap.java b/src/main/java/robaho/net/httpserver/OpenAddressIntMap.java index b60d0f2..411e82c 100644 --- a/src/main/java/robaho/net/httpserver/OpenAddressIntMap.java +++ b/src/main/java/robaho/net/httpserver/OpenAddressIntMap.java @@ -118,6 +118,8 @@ public int size() { public void clear() { Arrays.fill(entries, null); + size=0; + used=0; } public Iterable values() { diff --git a/src/main/java/robaho/net/httpserver/OpenAddressMap.java b/src/main/java/robaho/net/httpserver/OpenAddressMap.java index 24f4240..bc59899 100644 --- a/src/main/java/robaho/net/httpserver/OpenAddressMap.java +++ b/src/main/java/robaho/net/httpserver/OpenAddressMap.java @@ -19,6 +19,7 @@ private static class Entry { private int capacity; private int mask; private int size; + private int used; private Entry[] entries; public OpenAddressMap(int capacity) { @@ -37,6 +38,10 @@ public OpenAddressMap(int capacity) { } public Object put(String key, Object value) { + if(used>=capacity/2) { + resize(); + } + int index = key.hashCode() & mask; int start = index; int sentinel = -1; @@ -61,6 +66,7 @@ public Object put(String key, Object value) { } entries[sentinel==-1 ? index : sentinel] = new Entry(key, value); size++; + used++; return null; } @@ -74,6 +80,8 @@ private void resize() { this.entries = newMap.entries; this.capacity = newMap.capacity; this.mask = newMap.mask; + this.size = newMap.size; + this.used = newMap.used; } public Object get(String key) { @@ -98,6 +106,8 @@ public int size() { public void clear() { Arrays.fill(entries, null); + size=0; + used=0; } public void forEach(BiConsumer action) { From 03abd579210cfb9390fa479910204b8927cfe746 Mon Sep 17 00:00:00 2001 From: robert engels Date: Thu, 2 Jan 2025 14:42:06 -0600 Subject: [PATCH 21/78] version 1.0.15 --- README.md | 2 +- build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2a39cf1..879875f 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ The counts can be reset using `/__stats?reset`. The `requests/sec` is calculated io.github.robaho httpserver - 1.0.14 + 1.0.15 ``` ## enable Http2 diff --git a/build.gradle b/build.gradle index e70a56e..2fee7a8 100644 --- a/build.gradle +++ b/build.gradle @@ -156,7 +156,7 @@ publishing { maven(MavenPublication) { groupId = 'io.github.robaho' artifactId = 'httpserver' - version = "1.0.14" + version = "1.0.15" from components.java From a81cf703231812d78e1f86e1b7530afaaf438063 Mon Sep 17 00:00:00 2001 From: robert engels Date: Thu, 2 Jan 2025 15:02:46 -0600 Subject: [PATCH 22/78] need to flush non stream frames --- logging.properties | 2 +- src/main/java/robaho/net/httpserver/OpenAddressIntMap.java | 3 ++- src/main/java/robaho/net/httpserver/OpenAddressMap.java | 2 +- src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java | 3 +-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/logging.properties b/logging.properties index d273627..8afc3dd 100644 --- a/logging.properties +++ b/logging.properties @@ -3,4 +3,4 @@ handlers = java.util.logging.ConsoleHandler java.util.logging.ConsoleHandler.level = ALL java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter java.util.logging.SimpleFormatter.format=[%1$tF %1$tT] [%4$-7s] [%2$s] %5$s %6$s %n -robaho.net.level=INFO \ No newline at end of file +robaho.net.level=FINEST \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/OpenAddressIntMap.java b/src/main/java/robaho/net/httpserver/OpenAddressIntMap.java index 411e82c..c7326de 100644 --- a/src/main/java/robaho/net/httpserver/OpenAddressIntMap.java +++ b/src/main/java/robaho/net/httpserver/OpenAddressIntMap.java @@ -73,7 +73,8 @@ public synchronized T put(int key, T value) { return null; } entries[sentinel==-1 ? index : sentinel] = new Entry(key, value); - size++; used++; + size++; + if(sentinel!=-1) used++; return null; } diff --git a/src/main/java/robaho/net/httpserver/OpenAddressMap.java b/src/main/java/robaho/net/httpserver/OpenAddressMap.java index bc59899..ea16783 100644 --- a/src/main/java/robaho/net/httpserver/OpenAddressMap.java +++ b/src/main/java/robaho/net/httpserver/OpenAddressMap.java @@ -66,7 +66,7 @@ public Object put(String key, Object value) { } entries[sentinel==-1 ? index : sentinel] = new Entry(key, value); size++; - used++; + if(sentinel!=-1) used++; return null; } diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java index 9eb40d7..e218a94 100644 --- a/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java @@ -10,12 +10,10 @@ import java.util.ArrayList; import java.util.EnumSet; import java.util.List; -import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.Executor; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.LockSupport; import java.util.concurrent.locks.ReentrantLock; import com.sun.net.httpserver.Headers; @@ -140,6 +138,7 @@ public void writeFrame(List partials) throws IOException { for (var frame : partials) { outputStream.write(frame); } + outputStream.flush(); } finally { unlock(); } From e030806c936b5c730342aa6db5d8420ed233c15b Mon Sep 17 00:00:00 2001 From: robert engels Date: Thu, 2 Jan 2025 15:05:41 -0600 Subject: [PATCH 23/78] version 1.0.16 --- README.md | 2 +- build.gradle | 2 +- logging.properties | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 879875f..38965c0 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ The counts can be reset using `/__stats?reset`. The `requests/sec` is calculated io.github.robaho httpserver - 1.0.15 + 1.0.16 ``` ## enable Http2 diff --git a/build.gradle b/build.gradle index 2fee7a8..97a9d92 100644 --- a/build.gradle +++ b/build.gradle @@ -156,7 +156,7 @@ publishing { maven(MavenPublication) { groupId = 'io.github.robaho' artifactId = 'httpserver' - version = "1.0.15" + version = "1.0.16" from components.java diff --git a/logging.properties b/logging.properties index 8afc3dd..d273627 100644 --- a/logging.properties +++ b/logging.properties @@ -3,4 +3,4 @@ handlers = java.util.logging.ConsoleHandler java.util.logging.ConsoleHandler.level = ALL java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter java.util.logging.SimpleFormatter.format=[%1$tF %1$tT] [%4$-7s] [%2$s] %5$s %6$s %n -robaho.net.level=FINEST \ No newline at end of file +robaho.net.level=INFO \ No newline at end of file From 42f7413db878375e3d3a7103efe5366d41b471d3 Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 3 Jan 2025 08:34:57 -0600 Subject: [PATCH 24/78] reduce allocations in transferTo() --- .../net/httpserver/NoSyncBufferedInputStream.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/robaho/net/httpserver/NoSyncBufferedInputStream.java b/src/main/java/robaho/net/httpserver/NoSyncBufferedInputStream.java index 888c1bc..33c1da1 100644 --- a/src/main/java/robaho/net/httpserver/NoSyncBufferedInputStream.java +++ b/src/main/java/robaho/net/httpserver/NoSyncBufferedInputStream.java @@ -262,6 +262,15 @@ public long transferTo(OutputStream out) throws IOException { out.write(buf,pos,count-pos); pos = count; } - return super.transferTo(out); + long total = avail; + while (true) { + fill(); + if (count <= 0) { + break; + } + out.write(buf, 0, count); + total += count; + } + return total; } } From 0f78bef9c117c7c8bbaaa18637fcd83c60a4d39c Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 3 Jan 2025 08:35:40 -0600 Subject: [PATCH 25/78] make task names for running test cases consistent. ability to run a single unit test --- build.gradle | 49 ++++++++++++++++++++++++++++++------------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/build.gradle b/build.gradle index 97a9d92..a1ba98b 100644 --- a/build.gradle +++ b/build.gradle @@ -83,30 +83,19 @@ sourceSets { } } -task testMainsTest(type: Test) { - dependsOn testMainsClasses - doLast { - def files = sourceSets.testMains.allJava - def is = System.in - files.each { file -> - def fileWithoutExt = file.name.take(file.name.lastIndexOf('.')) - def props = systemProperties - println " *** $fileWithoutExt ***" - javaexec { - classpath sourceSets.testMains.runtimeClasspath - main fileWithoutExt - systemProperties props - standardInput is - } - } +task runSingleUnitTest(type: Test) { + dependsOn testClasses + filter { + includeTestsMatching 'InputNotRead' } + useTestNG() } /** used for developmet to run a single test */ -task testSingleTest(type: Test) { +task runSingleMainTest(type: Test) { dependsOn testMainsClasses doLast { - def testname = "Test1" + def testname = "SecureProxyHandlerTest" println jvmArgs println systemProperties def props = systemProperties @@ -119,6 +108,24 @@ task testSingleTest(type: Test) { } } +task testMainsTest(type: Test) { + dependsOn testMainsClasses + doLast { + def files = sourceSets.testMains.allJava + def is = System.in + files.each { file -> + def fileWithoutExt = file.name.take(file.name.lastIndexOf('.')) + def props = systemProperties + println " *** $fileWithoutExt ***" + javaexec { + classpath sourceSets.testMains.runtimeClasspath + main fileWithoutExt + systemProperties props + standardInput is + } + } + } +} task runSimpleFileServer(type: Test) { javaLauncher = javaToolchains.launcherFor { @@ -146,11 +153,15 @@ task run(type: JavaExec) { dependsOn testMainsClasses } -publish { +task runAllTests(type: Test) { dependsOn test dependsOn testMainsTest } +publish { + dependsOn runAllTests +} + publishing { publications { maven(MavenPublication) { From b3bc4538f12b02b8bbc18add92afe3412fd4524f Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 3 Jan 2025 08:36:14 -0600 Subject: [PATCH 26/78] less garbage generation with Huffman decoding in http2 --- .../robaho/net/httpserver/OpenAddressMap.java | 26 ++--- .../net/httpserver/OptimizedHeaders.java | 4 +- .../robaho/net/httpserver/ServerImpl.java | 2 +- .../net/httpserver/http2/HTTP2Connection.java | 10 +- .../net/httpserver/http2/HTTP2Stream.java | 48 ++++---- .../net/httpserver/http2/hpack/Huffman.java | 106 ++++++++++++++++-- src/test/java/EchoHandler.java | 31 +++-- .../java/robaho/net/httpserver/LogFilter.java | 28 +++-- 8 files changed, 178 insertions(+), 77 deletions(-) diff --git a/src/main/java/robaho/net/httpserver/OpenAddressMap.java b/src/main/java/robaho/net/httpserver/OpenAddressMap.java index ea16783..b7c7892 100644 --- a/src/main/java/robaho/net/httpserver/OpenAddressMap.java +++ b/src/main/java/robaho/net/httpserver/OpenAddressMap.java @@ -3,19 +3,17 @@ import java.util.Arrays; import java.util.function.BiConsumer; -public class OpenAddressMap { +public class OpenAddressMap { - private static class Entry { + private static class Entry { + K key; + V value; - String key; - Object value; - - Entry(String key, Object value) { + Entry(K key, V value) { this.key = key; this.value = value; } } - private int capacity; private int mask; private int size; @@ -37,7 +35,7 @@ public OpenAddressMap(int capacity) { this.entries = new Entry[capacity]; } - public Object put(String key, Object value) { + public V put(K key, V value) { if(used>=capacity/2) { resize(); } @@ -53,7 +51,7 @@ public Object put(String key, Object value) { if (value == null) { size--; } - return oldValue; + return (V)oldValue; } else if (entry.value == null) { sentinel = index; } @@ -72,7 +70,7 @@ public Object put(String key, Object value) { private void resize() { OpenAddressMap newMap = new OpenAddressMap(capacity << 1); - for (Entry entry : entries) { + for (var entry : entries) { if (entry != null) { newMap.put(entry.key, entry.value); } @@ -84,13 +82,13 @@ private void resize() { this.used = newMap.used; } - public Object get(String key) { + public V get(K key) { int index = key.hashCode() & mask; int start = index; Entry entry; while ((entry = entries[index]) != null) { if (entry.key.equals(key)) { - return entry.value; + return (V)entry.value; } index = (index + 1) & mask; if(index==start) { @@ -110,10 +108,10 @@ public void clear() { used=0; } - public void forEach(BiConsumer action) { + public void forEach(BiConsumer action) { for (Entry entry : entries) { if (entry != null && entry.value != null) { - action.accept(entry.key,entry.value); + action.accept((K)entry.key,(V)entry.value); } } } diff --git a/src/main/java/robaho/net/httpserver/OptimizedHeaders.java b/src/main/java/robaho/net/httpserver/OptimizedHeaders.java index ffab94a..a596d51 100644 --- a/src/main/java/robaho/net/httpserver/OptimizedHeaders.java +++ b/src/main/java/robaho/net/httpserver/OptimizedHeaders.java @@ -13,7 +13,7 @@ import com.sun.net.httpserver.Headers; public class OptimizedHeaders extends Headers { - private final OpenAddressMap map; + private final OpenAddressMap map; public OptimizedHeaders() { super(); map = new OpenAddressMap(16); @@ -83,8 +83,6 @@ private String normalize(String key) { } if(i==len) return key; - System.out.println("normalizing key: " + key); - char[] buffer = key.toCharArray(); for(;i0 ? Level.WARNING : Level.DEBUG,"IOException closing http2 stream",e); + logger.log(connection.httpConnection.requestCount.get()>0 ? Level.WARNING : Level.DEBUG, "IOException closing http2 stream",e); } } finally { } @@ -124,7 +125,7 @@ public void processFrame(BaseFrame frame) throws HTTP2Exception, IOException { break; case DATA: DataFrame dataFrame = (DataFrame) frame; - logger.log(Level.TRACE,"received data frame, length "+dataFrame.body.length+" on stream "+streamId); + logger.log(Level.TRACE,()->"received data frame, length "+dataFrame.body.length+" on stream "+streamId); if(halfClosed) { throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED); } @@ -132,7 +133,6 @@ public void processFrame(BaseFrame frame) throws HTTP2Exception, IOException { throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); } pipe.getOutputStream().write(dataFrame.body); - logger.log(Level.TRACE,"wrote data frame to pipe, length "+dataFrame.body.length+" on stream "+streamId); dataInSize += dataFrame.body.length; if (dataFrame.getHeader().getFlags().contains(FrameFlag.END_STREAM)) { if(requestHeaders.containsKey("Content-length")) { @@ -314,12 +314,13 @@ public void close() throws IOException { // custom Pipe implementation since JDK version still uses synchronized methods which are not optimal for virtual threads private static class Pipe { - private final CustomPipedInputStream inputStream; + private final InputStream inputStream; private final CustomPipedOutputStream outputStream; public Pipe() { - this.inputStream = new CustomPipedInputStream(); - this.outputStream = new CustomPipedOutputStream(this.inputStream); + var pipeIn = new CustomPipedInputStream(); + this.inputStream = new NoSyncBufferedInputStream(pipeIn); + this.outputStream = new CustomPipedOutputStream(pipeIn); } public InputStream getInputStream() { @@ -349,8 +350,16 @@ private static class CustomPipedInputStream extends InputStream { private final Condition notEmpty = lock.newCondition(); private final Condition notFull = lock.newCondition(); + private final byte[] single = new byte[1]; + @Override public int read() throws IOException { + int n = read(single, 0, 1); + return n == -1 ? -1 : single[0] & 0xFF; + } + + @Override + public int read(byte[] b, int off, int len) throws IOException { lock.lock(); try { while (readPos == writePos && !closed) { @@ -363,30 +372,27 @@ public int read() throws IOException { if (closed && readPos == writePos) { return -1; } - int result = buffer[readPos++] & 0xFF; + + int available; + if (readPos <= writePos) { + available = writePos - readPos; + } else { + available = buffer.length - readPos; + } + + int bytesToRead = Math.min(len, available); + System.arraycopy(buffer, readPos, b, off, bytesToRead); + readPos += bytesToRead; if (readPos == buffer.length) { readPos = 0; } notFull.signal(); - return result; + return bytesToRead; } finally { lock.unlock(); } } - @Override - public int read(byte[] b, int off, int len) throws IOException { - int bytesRead = 0; - while (bytesRead < len) { - int byteRead = read(); - if (byteRead == -1) { - return bytesRead == 0 ? -1 : bytesRead; - } - b[off + bytesRead++] = (byte) byteRead; - } - return bytesRead; - } - @Override public void close() throws IOException { lock.lock(); diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java b/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java index a9c2343..82b0b3a 100644 --- a/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java +++ b/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java @@ -6,18 +6,57 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.net.URISyntaxException; -import java.util.HashMap; +import robaho.net.httpserver.OpenAddressMap; import robaho.net.httpserver.http2.HTTP2ErrorCode; import robaho.net.httpserver.http2.HTTP2Exception; public class Huffman { - static HashMap huffmanCodes = null; + private static class HuffmanSequence { + private final char[] buffer; + private final int length; + private final int hash; + HuffmanSequence(char[] buffer, int length) { + this.buffer = buffer; + this.length = length; + this.hash = calculateHash(); + } + HuffmanSequence(char[] buffer) { + this(buffer, buffer.length); + } + private int calculateHash() { + int hash = 0; + for (int i = 0; i < length; i++) { + hash = 31 * hash + buffer[i]; + } + return hash; + } + @Override + public int hashCode() { + return hash; + } + @Override + public boolean equals(Object obj) { + if (!(obj instanceof HuffmanSequence other)) { + return false; + } + if (length != other.length || hash != other.hash) { + return false; + } + for (int i = 0; i < length; i++) { + if (buffer[i] != other.buffer[i]) { + return false; + } + } + return true; + } + } + static OpenAddressMap huffmanCodes = null; - public static HashMap getHuffmanCodes() + public static OpenAddressMap getHuffmanCodes() throws FileNotFoundException, IOException, URISyntaxException { if (huffmanCodes == null) { - huffmanCodes = new HashMap<>(); + huffmanCodes = new OpenAddressMap<>(512); ClassLoader classloader = Thread.currentThread().getContextClassLoader(); InputStream is = classloader.getResourceAsStream("huffman_codes_rfc7541.txt"); @@ -29,7 +68,7 @@ public static HashMap getHuffmanCodes() while ((line = br.readLine()) != null) { code = line.substring(11, line.indexOf(' ', 11)); code = code.replace("|", ""); - huffmanCodes.put(code, value); + huffmanCodes.put(new HuffmanSequence(code.toCharArray()), value); value++; } } @@ -41,7 +80,7 @@ public static HashMap getHuffmanCodes() public static String decode(byte[] value) throws HTTP2Exception { StringBuilder result = new StringBuilder(); - HashMap codes; + OpenAddressMap codes; try { codes = getHuffmanCodes(); } catch (IOException ex) { @@ -50,36 +89,79 @@ public static String decode(byte[] value) throws HTTP2Exception { throw new HTTP2Exception("Error reading huffman codes", ex); } - StringBuilder code = new StringBuilder(); + CodeBuffer code = new CodeBuffer(); for (int i = 0; i < value.length; i++) { int unsignedByte = value[i] & 0xff; for (int j = 0; j < 8; j++) { if ((unsignedByte & 0x00000080) != 0) { - code.append("1"); + code.append('1'); } else { - code.append("0"); + code.append('0'); } unsignedByte = unsignedByte << 1; - Integer intValue = codes.get(code.toString()); + Integer intValue = codes.get(code.sequence()); if(intValue!=null) { if(intValue==256) { throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR,"decoded contains EOS "+code); } result.append((char)(intValue & 0xFF)); - code.setLength(0); + code.reset(); } } } // Check for EOS (End of Stream) condition - if (code.length() > 7 || code.indexOf("0")>=0) { + if (code.length() > 7 || code.indexOf('0')>=0) { throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR,"decoded has incorrect padding "+code); } return result.toString(); } + private static class CodeBuffer { + private char[] buffer = new char[32]; + private int length; + + void append(char ch) { + if (length >= buffer.length) { + char[] newBuffer = new char[buffer.length * 2]; + System.arraycopy(buffer, 0, newBuffer, 0, buffer.length); + buffer = newBuffer; + } + buffer[length++] = ch; + } + + void reset() { + length = 0; + } + + int length() { + return length; + } + + int indexOf(char c) { + for (int i = 0; i < length; i++) { + if (buffer[i] == c) { + return i; + } + } + return -1; + } + + HuffmanSequence sequence() { + return new HuffmanSequence(buffer, length); + } + + @Override + public String toString() { + return new String(buffer, 0, length); + } + } + + + + } diff --git a/src/test/java/EchoHandler.java b/src/test/java/EchoHandler.java index c3bbea7..2879e83 100644 --- a/src/test/java/EchoHandler.java +++ b/src/test/java/EchoHandler.java @@ -38,23 +38,30 @@ public void handle (HttpExchange t) throws IOException { InputStream is = t.getRequestBody(); + OutputStream os = t.getResponseBody(); + Headers map = t.getRequestHeaders(); - String fixedrequest = map.getFirst ("XFixed"); + String fixedrequest = map.getFirst("Xfixed"); // return the number of bytes received (no echo) - String summary = map.getFirst ("XSummary"); - OutputStream os = t.getResponseBody(); - byte[] in; - in = is.readAllBytes(); - if (summary != null) { - in = Integer.toString(in.length).getBytes(StandardCharsets.UTF_8); - } - if (fixedrequest != null) { - t.sendResponseHeaders(200, in.length == 0 ? -1 : in.length); - } else { + String summary = map.getFirst ("Xsummary"); + + if(fixedrequest==null && summary==null) { t.sendResponseHeaders(200, 0); + is.transferTo(os); + } else { + byte[] in; + in = is.readAllBytes(); + if (summary != null) { + in = Integer.toString(in.length).getBytes(StandardCharsets.UTF_8); + } + if (fixedrequest != null) { + t.sendResponseHeaders(200, in.length == 0 ? -1 : in.length); + } else { + t.sendResponseHeaders(200, 0); + } + os.write(in); } - os.write(in); close(t, os); close(t, is); } diff --git a/src/test/java/robaho/net/httpserver/LogFilter.java b/src/test/java/robaho/net/httpserver/LogFilter.java index 8c9729e..108bc6b 100644 --- a/src/test/java/robaho/net/httpserver/LogFilter.java +++ b/src/test/java/robaho/net/httpserver/LogFilter.java @@ -22,19 +22,20 @@ * questions. */ -import java.util.*; -import java.text.*; import java.io.*; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + import com.sun.net.httpserver.*; public class LogFilter extends Filter { PrintStream ps; - DateFormat df; + DateTimeFormatter df; public LogFilter(File file) throws IOException { - ps = new PrintStream(new FileOutputStream(file)); - df = DateFormat.getDateTimeInstance(); + ps = new PrintStream(new BufferedOutputStream(new FileOutputStream(file))); + df = DateTimeFormatter.ISO_DATE_TIME; } /** @@ -42,12 +43,17 @@ public LogFilter(File file) throws IOException { */ public void doFilter(HttpExchange t, Filter.Chain chain) throws IOException { chain.doFilter(t); - HttpContext context = t.getHttpContext(); - Headers rmap = t.getRequestHeaders(); - String s = df.format(new Date()); - s = s + " " + t.getRequestMethod() + " " + t.getRequestURI() + " "; - s = s + " " + t.getResponseCode() + " " + t.getRemoteAddress(); - ps.println(s); + StringBuilder sb = new StringBuilder(); + df.formatTo(LocalDateTime.now(),sb); + sb.append(" "); + sb.append(t.getRequestMethod()); + sb.append(" "); + sb.append(t.getRequestURI()); + sb.append(" "); + sb.append(t.getResponseCode()); + sb.append(" "); + sb.append(t.getRemoteAddress()); + ps.println(sb.toString()); } public void init(HttpContext ctx) { From 707d7e3a79a2e015903a89a30b3fa0c4bf173e9a Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 3 Jan 2025 09:13:22 -0600 Subject: [PATCH 27/78] fix test to send valid request, since according to 4.4, if Content-length is present it must match the number of bytes in the body --- src/test/test_mains/B6361557.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/test_mains/B6361557.java b/src/test/test_mains/B6361557.java index 57e831e..39c96a7 100644 --- a/src/test/test_mains/B6361557.java +++ b/src/test/test_mains/B6361557.java @@ -65,7 +65,7 @@ public void handle (HttpExchange t) } final static String request = "GET /test/foo.html HTTP/1.1\r\nContent-length: 0\r\n\r\n"; - final static ByteBuffer requestBuf = ByteBuffer.allocate(64).put(request.getBytes()); + final static ByteBuffer requestBuf = ByteBuffer.wrap(request.getBytes()); public static void main (String[] args) throws Exception { Handler handler = new Handler(); From 86440d068babdbd2ba2d5aced795042ddb8b7574 Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 3 Jan 2025 09:58:00 -0600 Subject: [PATCH 28/78] improve performance by removing unnecessary flushes --- .../java/robaho/net/httpserver/ChunkedOutputStream.java | 4 +++- src/main/java/robaho/net/httpserver/ExchangeImpl.java | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/robaho/net/httpserver/ChunkedOutputStream.java b/src/main/java/robaho/net/httpserver/ChunkedOutputStream.java index 8f0f753..7b4c0d2 100644 --- a/src/main/java/robaho/net/httpserver/ChunkedOutputStream.java +++ b/src/main/java/robaho/net/httpserver/ChunkedOutputStream.java @@ -132,7 +132,9 @@ public void close() throws IOException { if (closed) { return; } - flush(); + if (count > 0) { + writeChunk(); + } try { /* write an empty chunk */ writeChunk(); diff --git a/src/main/java/robaho/net/httpserver/ExchangeImpl.java b/src/main/java/robaho/net/httpserver/ExchangeImpl.java index 956838b..3538437 100644 --- a/src/main/java/robaho/net/httpserver/ExchangeImpl.java +++ b/src/main/java/robaho/net/httpserver/ExchangeImpl.java @@ -239,6 +239,8 @@ public void sendResponseHeaders(int rCode, long contentLen) getConnection().getSocket().setOption(StandardSocketOptions.SO_SNDBUF, bufferSize); } + boolean flush = false; + /* check for response type that is not allowed to send a body */ if (rCode == 101) { logger.log(Level.DEBUG, () -> "switching protocols"); @@ -249,6 +251,7 @@ public void sendResponseHeaders(int rCode, long contentLen) logger.log(Level.WARNING, msg); } contentLen = 0; + flush = true; } else if ((rCode >= 100 && rCode < 200) /* informational */ || (rCode == 204) /* no content */ @@ -280,6 +283,7 @@ public void sendResponseHeaders(int rCode, long contentLen) if (websocket || isConnectRequest()) { o.setWrappedStream(ros); close = true; + flush = true; } else if (http10) { o.setWrappedStream(new UndefLengthOutputStream(this, ros)); @@ -323,7 +327,7 @@ else if (http10) { if(logger.isLoggable(Level.TRACE)) { logger.log(Level.TRACE, "Sent headers: noContentToSend=" + noContentToSend); } - if(contentLen==0) { + if(flush) { ros.flush(); } if (noContentToSend) { From f8f2e9f0f2b1ebb4f1e1b4c6259c794a73097039 Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 3 Jan 2025 11:24:53 -0600 Subject: [PATCH 29/78] remove redundant normalization --- .../java/robaho/net/httpserver/OptimizedHeaders.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/java/robaho/net/httpserver/OptimizedHeaders.java b/src/main/java/robaho/net/httpserver/OptimizedHeaders.java index a596d51..c57050b 100644 --- a/src/main/java/robaho/net/httpserver/OptimizedHeaders.java +++ b/src/main/java/robaho/net/httpserver/OptimizedHeaders.java @@ -40,7 +40,7 @@ public List get(Object key) { @Override public List put(String key, List value) { - Object o = map.put(normalize((String)key), value); + Object o = map.put(normalize(key), value); return o == null ? null : (o instanceof String) ? Arrays.asList((String)o) : (List)o; } @@ -52,7 +52,7 @@ public List remove(Object key) { @Override public String getFirst(String key) { - Object o = map.get(normalize((String)key)); + Object o = map.get(normalize(key)); return o == null ? null : (o instanceof String) ? (String)o : ((List)o).getFirst(); } @@ -98,11 +98,12 @@ private String normalize(String key) { @Override public void add(String key, String value) { - Object o = map.get(normalize((String)key)); + var normalized = normalize(key); + Object o = map.get(normalized); if (o == null) { - map.put(normalize(key), value); + map.put(normalized, value); } else if(o instanceof String) { - map.put(normalize(key), Arrays.asList((String)o,value)); + map.put(normalized, Arrays.asList((String)o,value)); } else { ((List)o).add(value); } From 382c51fec70cb57ad732f98e36dae2bb50e34876 Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 3 Jan 2025 11:43:04 -0600 Subject: [PATCH 30/78] optimize encoded of headers in HPack --- .../net/httpserver/http2/hpack/HPackContext.java | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java b/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java index 9b6125d..c417822 100644 --- a/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java +++ b/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java @@ -2,13 +2,9 @@ import java.io.IOException; import java.io.OutputStream; -import java.lang.reflect.Array; -import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; -import java.util.Deque; import java.util.EnumSet; -import java.util.LinkedList; import robaho.net.httpserver.http2.HTTP2ErrorCode; import robaho.net.httpserver.http2.HTTP2Exception; @@ -20,7 +16,6 @@ import robaho.net.httpserver.http2.frame.FrameFlag; import robaho.net.httpserver.http2.frame.FrameHeader; -import robaho.net.httpserver.http2.frame.FrameType; import robaho.net.httpserver.http2.Utils; import robaho.net.httpserver.http2.frame.FrameType; @@ -43,7 +38,7 @@ public void addHeaderField(HTTP2HeaderField field) { } public List decodeFieldSegments(byte[] buffer) throws HTTP2Exception { - List headers = new ArrayList<>(); + List headers = new ArrayList<>(8); int index = 0; try { @@ -232,10 +227,12 @@ private static byte[] encodeHeaders(Headers headers) { List pseudo = new ArrayList<>(6); headers.forEach((name, values) -> { for (String value : values) { - byte[] header = encodeHeader(name.toLowerCase(), value); if(name.startsWith(":")) { + byte[] header = encodeHeader(name, value); pseudo.add(header); } else { + // Headers keys are normalized to the first letter in uppercase and the rest in lowercase + byte[] header = encodeHeader(Character.toLowerCase(name.charAt(0))+name.substring(1), value); fields.add(header); } } From 68d7bf8aa8048583c47ec041c3bd06aa69700b94 Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 3 Jan 2025 13:25:40 -0600 Subject: [PATCH 31/78] minor optimizations --- .../robaho/net/httpserver/OptimizedHeaders.java | 17 +++-------------- .../java/robaho/net/httpserver/ServerImpl.java | 2 +- .../net/httpserver/http2/HTTP2Stream.java | 4 +++- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/src/main/java/robaho/net/httpserver/OptimizedHeaders.java b/src/main/java/robaho/net/httpserver/OptimizedHeaders.java index c57050b..918c60a 100644 --- a/src/main/java/robaho/net/httpserver/OptimizedHeaders.java +++ b/src/main/java/robaho/net/httpserver/OptimizedHeaders.java @@ -72,28 +72,17 @@ private String normalize(String key) { if (c == '\r' || c == '\n') throw new IllegalArgumentException("illegal character in key"); if(i==0) { - if (c >= 'a' && c <= 'z') { + if (Character.isLowerCase(c)) { break; } } else { - if (c >= 'A' && c <= 'Z') { + if (Character.isUpperCase(c)) { break; } } } if(i==len) return key; - - char[] buffer = key.toCharArray(); - for(;i= 'a' && c <= 'z') { - buffer[i] = (char)(c - ('a' - 'A')); - } else if (i!=0 && c >= 'A' && c <= 'Z') { - buffer[i] = (char) (c + ('a' - 'A')); - } else if (c == '\r' || c == '\n') - throw new IllegalArgumentException("illegal character in key"); - } - return new String(buffer); + return Character.toUpperCase(key.charAt(0))+key.substring(1).toLowerCase(); } @Override diff --git a/src/main/java/robaho/net/httpserver/ServerImpl.java b/src/main/java/robaho/net/httpserver/ServerImpl.java index 1e8c1f7..3cc18f1 100644 --- a/src/main/java/robaho/net/httpserver/ServerImpl.java +++ b/src/main/java/robaho/net/httpserver/ServerImpl.java @@ -552,7 +552,7 @@ public void handleStream(HTTP2Stream stream,InputStream in, OutputStream out) th throw new IOException("Invalid HTTP/2 headers: missing :authority or :path"); } - request.add("Host",authority); + request.set("Host",authority); URI uri; try { diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java index ea3ae6b..7ed3604 100644 --- a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java @@ -64,7 +64,9 @@ public HTTP2Stream(int streamId, HTTP2Connection connection, Headers requestHead if(setting!=null) { sendWindow.addAndGet((int)(setting.value-65535)); } - logger.log(Level.TRACE,() -> "new stream, window size "+sendWindow.get()+" on stream "+streamId); + if(logger.isLoggable(Level.TRACE)) { + logger.log(Level.TRACE,() -> "new stream, window size "+sendWindow.get()+" on stream "+streamId); + } } public OutputStream getOutputStream() { From 78e83b1c19698bdf0ff01497af3948d48f6e1282 Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 3 Jan 2025 18:24:02 -0600 Subject: [PATCH 32/78] optimize huffman coding --- .../java/robaho/net/httpserver/BloomSet.java | 25 ++++ .../robaho/net/httpserver/OpenAddressMap.java | 34 ++++- .../net/httpserver/OptimizedHeaders.java | 12 +- .../robaho/net/httpserver/ServerImpl.java | 2 +- .../net/httpserver/http2/HTTP2Connection.java | 3 +- .../net/httpserver/http2/HTTP2Stream.java | 3 +- .../robaho/net/httpserver/http2/Utils.java | 24 ++++ .../net/httpserver/http2/frame/FrameFlag.java | 129 +++++++++--------- .../httpserver/http2/frame/FrameHeader.java | 24 ++-- .../httpserver/http2/frame/HeadersFrame.java | 3 +- .../net/httpserver/http2/frame/PingFrame.java | 3 +- .../httpserver/http2/frame/SettingsFrame.java | 4 +- .../httpserver/http2/hpack/HPackContext.java | 15 +- .../httpserver/http2/hpack/HeaderFields.java | 10 +- .../net/httpserver/http2/hpack/Huffman.java | 84 ++++++++++-- 15 files changed, 264 insertions(+), 111 deletions(-) create mode 100644 src/main/java/robaho/net/httpserver/BloomSet.java diff --git a/src/main/java/robaho/net/httpserver/BloomSet.java b/src/main/java/robaho/net/httpserver/BloomSet.java new file mode 100644 index 0000000..b73953a --- /dev/null +++ b/src/main/java/robaho/net/httpserver/BloomSet.java @@ -0,0 +1,25 @@ +package robaho.net.httpserver; + +/** small set designed for efficient negative contains */ +public class BloomSet { + private final int bloomHash; + private OpenAddressMap values; + private BloomSet(String... values) { + this.values = new OpenAddressMap<>(values.length*2); + int bloomHash = 0; + for(var v : values) { + bloomHash = bloomHash | v.hashCode(); + this.values.put(v,true); + } + this.bloomHash = bloomHash; + } + public static BloomSet of(String... values) { + return new BloomSet(values); + } + public boolean contains(String value) { + return (bloomHash & value.hashCode()) == value.hashCode() && Boolean.TRUE.equals(values.get(value)); + } + public Iterable values() { + return values.keys(); + } +} \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/OpenAddressMap.java b/src/main/java/robaho/net/httpserver/OpenAddressMap.java index b7c7892..d50a419 100644 --- a/src/main/java/robaho/net/httpserver/OpenAddressMap.java +++ b/src/main/java/robaho/net/httpserver/OpenAddressMap.java @@ -20,6 +20,11 @@ private static class Entry { private int used; private Entry[] entries; + private static int hash(int hash) { + return hash; + // return hash ^ (hash>>>16); + } + public OpenAddressMap(int capacity) { // round up to next power of 2 capacity--; @@ -40,7 +45,7 @@ public V put(K key, V value) { resize(); } - int index = key.hashCode() & mask; + int index = hash(key.hashCode()) & mask; int start = index; int sentinel = -1; Entry entry; @@ -58,7 +63,7 @@ public V put(K key, V value) { index = (index + 1) & mask; if (index == start) { resize(); - index = key.hashCode() & mask; + index = hash(key.hashCode()) & mask; start = index; } } @@ -83,7 +88,7 @@ private void resize() { } public V get(K key) { - int index = key.hashCode() & mask; + int index = hash(key.hashCode()) & mask; int start = index; Entry entry; while ((entry = entries[index]) != null) { @@ -115,4 +120,27 @@ public void forEach(BiConsumer action) { } } } + public Iterable keys() { + return () -> new KeyIterator(); + } + + private class KeyIterator implements java.util.Iterator { + private int index = 0; + + @Override + public boolean hasNext() { + while (index < entries.length) { + if (entries[index] != null && entries[index].value != null) { + return true; + } + index++; + } + return false; + } + + @Override + public K next() { + return (K) entries[index++].key; + } + } } diff --git a/src/main/java/robaho/net/httpserver/OptimizedHeaders.java b/src/main/java/robaho/net/httpserver/OptimizedHeaders.java index 918c60a..a893399 100644 --- a/src/main/java/robaho/net/httpserver/OptimizedHeaders.java +++ b/src/main/java/robaho/net/httpserver/OptimizedHeaders.java @@ -12,7 +12,11 @@ import com.sun.net.httpserver.Headers; +import robaho.net.httpserver.http2.hpack.HPackContext; + public class OptimizedHeaders extends Headers { + private static final BloomSet commonKeys = BloomSet.of(HPackContext.getStaticHeaderNames().stream().map(s -> (Character.toUpperCase(s.charAt(0))+s.substring(1))).toArray(String[]::new)); + private final OpenAddressMap map; public OptimizedHeaders() { super(); @@ -61,10 +65,12 @@ public String getFirst(String key) { * First {@code char} upper case, rest lower case. * key is presumed to be {@code ASCII}. */ - private String normalize(String key) { + private static String normalize(String key) { int len = key.length(); if(len==0) return key; + if(commonKeys.contains(key)) return key; + int i=0; for(;i= 'a' && c <= 'z') { break; } } else { - if (Character.isUpperCase(c)) { + if (c >= 'A' && c <= 'Z') { break; } } diff --git a/src/main/java/robaho/net/httpserver/ServerImpl.java b/src/main/java/robaho/net/httpserver/ServerImpl.java index 3cc18f1..fce1c6a 100644 --- a/src/main/java/robaho/net/httpserver/ServerImpl.java +++ b/src/main/java/robaho/net/httpserver/ServerImpl.java @@ -511,7 +511,7 @@ public void run() { http2.sendMySettings(); http2.handle(); } catch (HTTP2Exception ex) { - logger.log(Level.WARNING, "ServerImpl http2 protocol exception "+http2, ex.getMessage()); + logger.log(Level.WARNING, "ServerImpl http2 protocol exception "+http2,ex); } catch (EOFException | SocketException ex) { logger.log(Level.DEBUG, "end of stream "+http2); } catch (Exception ex) { diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java index 8f6be16..4202507 100644 --- a/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java @@ -30,6 +30,7 @@ import robaho.net.httpserver.http2.frame.ContinuationFrame; import robaho.net.httpserver.http2.frame.DataFrame; import robaho.net.httpserver.http2.frame.FrameFlag; +import robaho.net.httpserver.http2.frame.FrameFlag.FlagSet; import robaho.net.httpserver.http2.frame.FrameHeader; import robaho.net.httpserver.http2.frame.FrameSerializer; import robaho.net.httpserver.http2.frame.FrameType; @@ -373,7 +374,7 @@ public void updateRemoteSettings(SettingsFrame remoteSettingFrame) throws HTTP2E public void sendSettingsAck() throws IOException { try { - byte[] frame = FrameHeader.encode(0, FrameType.SETTINGS, EnumSet.of(FrameFlag.ACK), 0); + byte[] frame = FrameHeader.encode(0, FrameType.SETTINGS, FlagSet.of(FrameFlag.ACK), 0); HTTP2Connection.this.writeFrame(frame); } finally { logger.log(Level.TRACE, () -> "sent Settings Ack"); diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java index 7ed3604..59020d7 100644 --- a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java @@ -24,6 +24,7 @@ import robaho.net.httpserver.http2.frame.BaseFrame; import robaho.net.httpserver.http2.frame.DataFrame; import robaho.net.httpserver.http2.frame.FrameFlag; +import robaho.net.httpserver.http2.frame.FrameFlag.FlagSet; import robaho.net.httpserver.http2.frame.FrameHeader; import robaho.net.httpserver.http2.frame.FrameType; import robaho.net.httpserver.http2.frame.ResetStreamFrame; @@ -210,7 +211,7 @@ public InetSocketAddress getRemoteAddress() { } class Http2OutputStream extends OutputStream { - private static final EnumSet END_STREAM = EnumSet.of(FrameFlag.END_STREAM); + private static final FlagSet END_STREAM = FlagSet.of(FrameFlag.END_STREAM); private final int streamId; private final int max_frame_size; diff --git a/src/main/java/robaho/net/httpserver/http2/Utils.java b/src/main/java/robaho/net/httpserver/http2/Utils.java index 7c38a44..5cf6eb1 100644 --- a/src/main/java/robaho/net/httpserver/http2/Utils.java +++ b/src/main/java/robaho/net/httpserver/http2/Utils.java @@ -80,6 +80,30 @@ public static byte[] combineByteArrays(List blocks) { offset += block.length; } + return combined; + } + public static byte[] combineByteArrays(List array1,List array2) { + int totalLength = 0; + for (byte[] block : array1) { + totalLength += block.length; + } + for (byte[] block : array2) { + totalLength += block.length; + } + if(totalLength==0) return EMPTY; + + byte[] combined = new byte[totalLength]; + + int offset = 0; + for (byte[] block : array1) { + System.arraycopy(block, 0, combined, offset, block.length); + offset += block.length; + } + for (byte[] block : array2) { + System.arraycopy(block, 0, combined, offset, block.length); + offset += block.length; + } + return combined; } } \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/http2/frame/FrameFlag.java b/src/main/java/robaho/net/httpserver/http2/frame/FrameFlag.java index ac42899..8810fab 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/FrameFlag.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/FrameFlag.java @@ -1,9 +1,5 @@ package robaho.net.httpserver.http2.frame; -import java.util.Collections; -import java.util.EnumSet; -import java.util.Set; - import robaho.net.httpserver.http2.HTTP2Exception; /** @@ -11,78 +7,81 @@ */ public enum FrameFlag { - END_STREAM((byte)0x1), - ACK((byte)0x1), - END_HEADERS((byte)0x4), - PADDED((byte)0x8), - PRIORITY((byte)0x20); - - private final byte value; - - FrameFlag(byte value) { - this.value = value; - } + END_STREAM((byte) 0x1), + ACK((byte) 0x1), + END_HEADERS((byte) 0x4), + PADDED((byte) 0x8), + PRIORITY((byte) 0x20); + private final byte value; + private static final byte MASK = (byte) (END_STREAM.value | END_HEADERS.value | PADDED.value | PRIORITY.value); private static final FrameFlag[] _values = FrameFlag.values(); - public static final Set NONE = Collections.unmodifiableSet(EnumSet.noneOf(FrameFlag.class)); + FrameFlag(byte value) { + this.value = value; + } - public byte getValue() { - return value; - } + public static final FlagSet NONE = new FlagSet(0,false); - public static Set getEnumSet(byte value, FrameType type) throws HTTP2Exception { - if(value==0) { + public byte getValue() { + return value; + } + + public static FlagSet getEnumSet(byte value, FrameType type) throws HTTP2Exception { + if (value == 0) { return NONE; } + return new FlagSet(value & MASK, type == FrameType.SETTINGS || type == FrameType.PING); + } - // Empty EnumSet - EnumSet result = EnumSet.noneOf(FrameFlag.class); + public static class FlagSet { - // Check if the first bit is set - if((value & 1) == 1) - { - // for SETTING and PING frames the first bit indicates whether the frame is ACK - if(type == FrameType.SETTINGS || type == FrameType.PING) - { - result.add(FrameFlag.ACK); - } - else - { - result.add(FrameFlag.END_STREAM); - } - - // reset the first bit - value = (byte)(value ^ 1); - } + private final int value; + private final boolean isAck; - // For each flag in FrameFlag - for (FrameFlag flag : _values) { - // Check whether the flag bit is set - if ((value & flag.value) != 0) { - result.add(flag); - - // reset the flag bit - value = (byte)(value ^ flag.value); - } - } - - if(value != 0) { - // Unknown bit flag is set, according to the spec we should ignore it - // throw new HTTP2Exception(HTTP2ErrorCode.CONNECT_ERROR, "Unknown bit flag is set: " + value); + FlagSet(int value, boolean isAck) { + this.value = value; + this.isAck = isAck; } - return result; - } - - public static byte getValue(Set flags) { - - byte result = 0; - - for (FrameFlag flag : flags) { - result = (byte) (result | flag.getValue()); - } + public byte value() { + return (byte) value; + } + public boolean contains(FrameFlag flag) { + return (value & flag.value) == flag.value; + } + public static FlagSet of(FrameFlag... flags) { + int value = 0; + boolean isAck = false; + for (FrameFlag flag : flags) { + value |= flag.value; + if (flag == ACK) { + isAck = true; + } + } + return new FlagSet(value, isAck); + } - return result; - } + @Override + public String toString() { + StringBuilder sb = new StringBuilder("["); + var tmp = this.value; + + if ((tmp & 1) == 1) { + sb.append(isAck ? "ACK" : "END_STREAM"); + // reset the first bit + tmp = (byte) (tmp ^ 1); + } + for (FrameFlag flag : FrameFlag._values) { + if ((tmp & flag.value) == flag.value) { + if(!sb.isEmpty()) { + sb.append(","); + } + sb.append(flag); + } + } + sb.append("]"); + return sb.toString(); + } + } } diff --git a/src/main/java/robaho/net/httpserver/http2/frame/FrameHeader.java b/src/main/java/robaho/net/httpserver/http2/frame/FrameHeader.java index 059771f..0e8b4b6 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/FrameHeader.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/FrameHeader.java @@ -2,9 +2,9 @@ import java.io.IOException; import java.io.OutputStream; -import java.util.Set; import robaho.net.httpserver.http2.Utils; +import robaho.net.httpserver.http2.frame.FrameFlag.FlagSet; /** * Create a frame header object @@ -13,7 +13,7 @@ public class FrameHeader { private final int length; private final FrameType type; - private final Set flags; + private final FlagSet flags; private final int streamIdentifier; /** @@ -36,7 +36,7 @@ public FrameType getType() { * defined as an EnumSet<FrameFlag>, it identifies flags associated with a * particular frame */ - public Set getFlags() { + public FlagSet getFlags() { return flags; } @@ -47,7 +47,7 @@ public int getStreamIdentifier() { return streamIdentifier; } - public FrameHeader(int length, FrameType type, Set flags, int streamIdentifier) { + public FrameHeader(int length, FrameType type, FlagSet flags, int streamIdentifier) { this.length = length; this.type = type; this.flags = flags; @@ -75,7 +75,7 @@ public static FrameHeader Parse(byte[] tmpBuffer) throws Exception { FrameHeader frameHeader = null; FrameType type = null; - Set flag = null; + FlagSet flag = null; int streamIdentifier = 0; int length = 0; int readIndex = 0; @@ -111,22 +111,22 @@ public static String debug(byte[] header) { public void writeTo(OutputStream os) throws IOException { Utils.writeBinary(os,this.length,3); os.write(this.getType().value & 0xFF); - os.write(FrameFlag.getValue(this.getFlags()) & 0xFF); + os.write(flags.value()); Utils.writeBinary(os,this.streamIdentifier); } - public static void writeTo(OutputStream os, int length,FrameType frameType,Set flags,int streamId) throws IOException { + public static void writeTo(OutputStream os, int length,FrameType frameType,FlagSet flags,int streamId) throws IOException { Utils.writeBinary(os,length,3); os.write(frameType.value & 0xFF); - os.write(FrameFlag.getValue(flags) & 0xFF); + os.write(flags.value()); Utils.writeBinary(os,streamId); } - public static byte[] encode(int length,FrameType frameType,Set flags,int streamId) { + public static byte[] encode(int length,FrameType frameType,FlagSet flags,int streamId) { byte[] buffer = new byte[9]; Utils.convertToBinary(buffer, 0, length, 3); buffer[3] = (byte)(frameType.value & 0xFF); - buffer[4] = (byte)(FrameFlag.getValue(flags) & 0xFF); + buffer[4] = (byte)(flags.value()); Utils.convertToBinary(buffer, 5, streamId,4); return buffer; } @@ -135,7 +135,7 @@ public byte[] encode() { byte[] buffer = new byte[9]; Utils.convertToBinary(buffer, 0, length ,3); buffer[3] = (byte)(type.value & 0xFF); - buffer[4] = (byte)(FrameFlag.getValue(flags) & 0xFF); + buffer[4] = (byte)(flags.value()); Utils.convertToBinary(buffer, 5, streamIdentifier,4); return buffer; } @@ -144,7 +144,7 @@ public byte[] encode() { public byte[] encode(byte[] buffer) { Utils.convertToBinary(buffer, 0, length ,3); buffer[3] = (byte)(type.value & 0xFF); - buffer[4] = (byte)(FrameFlag.getValue(flags) & 0xFF); + buffer[4] = (byte)(flags.value()); Utils.convertToBinary(buffer, 5, streamIdentifier,4); return buffer; } diff --git a/src/main/java/robaho/net/httpserver/http2/frame/HeadersFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/HeadersFrame.java index 14057f5..46f7023 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/HeadersFrame.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/HeadersFrame.java @@ -8,6 +8,7 @@ import robaho.net.httpserver.http2.HTTP2ErrorCode; import robaho.net.httpserver.http2.HTTP2Exception; import robaho.net.httpserver.http2.Utils; +import robaho.net.httpserver.http2.frame.FrameFlag.FlagSet; /** * [rfc7540 Section 6.2] The HEADERS frame (type=0x1) is used to open a stream @@ -165,7 +166,7 @@ public static HeadersFrame parse(byte[] frameBody, FrameHeader header) throws HT @Override public void writeTo(OutputStream os) throws IOException { byte[] buffer = getHeaderBlock(); - FrameHeader.writeTo(os, buffer.length, FrameType.HEADERS, EnumSet.of(FrameFlag.END_HEADERS), getHeader().getStreamIdentifier()); + FrameHeader.writeTo(os, buffer.length, FrameType.HEADERS, FlagSet.of(FrameFlag.END_HEADERS), getHeader().getStreamIdentifier()); os.write(buffer); os.flush(); } diff --git a/src/main/java/robaho/net/httpserver/http2/frame/PingFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/PingFrame.java index 13610a8..d27a70d 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/PingFrame.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/PingFrame.java @@ -8,6 +8,7 @@ import robaho.net.httpserver.http2.HTTP2ErrorCode; import robaho.net.httpserver.http2.HTTP2Exception; import robaho.net.httpserver.http2.Utils; +import robaho.net.httpserver.http2.frame.FrameFlag.FlagSet; public class PingFrame extends BaseFrame { public final byte[] body; @@ -21,7 +22,7 @@ public PingFrame() { body = new byte[8]; } public PingFrame(PingFrame toBeAcked) { - super(new FrameHeader(toBeAcked.body.length,FrameType.PING,EnumSet.of(FrameFlag.ACK),0)); + super(new FrameHeader(toBeAcked.body.length,FrameType.PING,FlagSet.of(FrameFlag.ACK),0)); body = toBeAcked.body; } diff --git a/src/main/java/robaho/net/httpserver/http2/frame/SettingsFrame.java b/src/main/java/robaho/net/httpserver/http2/frame/SettingsFrame.java index 2baa120..ba6666d 100644 --- a/src/main/java/robaho/net/httpserver/http2/frame/SettingsFrame.java +++ b/src/main/java/robaho/net/httpserver/http2/frame/SettingsFrame.java @@ -5,10 +5,10 @@ import java.io.OutputStream; import java.util.ArrayList; import java.util.Arrays; -import java.util.EnumSet; import robaho.net.httpserver.http2.HTTP2ErrorCode; import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.frame.FrameFlag.FlagSet; public class SettingsFrame extends BaseFrame { @@ -18,7 +18,7 @@ public class SettingsFrame extends BaseFrame { * SettingsFrame Constructor which calls the parameterized constructor */ public SettingsFrame() { - this(new FrameHeader(0, FrameType.SETTINGS, EnumSet.of(FrameFlag.ACK) , 0)); + this(new FrameHeader(0, FrameType.SETTINGS, FlagSet.of(FrameFlag.ACK) , 0)); } public SettingsFrame(FrameHeader header) { diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java b/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java index c417822..3beb5bd 100644 --- a/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java +++ b/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java @@ -4,7 +4,7 @@ import java.io.OutputStream; import java.util.ArrayList; import java.util.Arrays; -import java.util.EnumSet; +import java.util.Collection; import robaho.net.httpserver.http2.HTTP2ErrorCode; import robaho.net.httpserver.http2.HTTP2Exception; @@ -17,6 +17,7 @@ import robaho.net.httpserver.http2.frame.FrameFlag; import robaho.net.httpserver.http2.frame.FrameHeader; import robaho.net.httpserver.http2.Utils; +import robaho.net.httpserver.http2.frame.FrameFlag.FlagSet; import robaho.net.httpserver.http2.frame.FrameType; public class HPackContext { @@ -25,6 +26,10 @@ public class HPackContext { public HPackContext() { } + public static Collection getStaticHeaderNames() { + return RFC7541Parser.getStaticHeaderNames(); + } + public HTTP2HeaderField getHeaderField(int index) { if (index > 0 && index <= 61) { return RFC7541Parser.getHeaderField(index); @@ -214,7 +219,7 @@ public static void writeHeaderFrame(Headers headers, OutputStream outputStream, // System.out.println("HPACK.writeHeaderFrame: wrote header frame, length: " + buffer.length + ", streamId: " + streamId); } - private static final EnumSet END_OF_HEADERS = EnumSet.of(FrameFlag.END_HEADERS); + private static final FlagSet END_OF_HEADERS = FlagSet.of(FrameFlag.END_HEADERS); public static List encodeHeadersFrame(Headers headers,int streamId) { byte[] buffer = encodeHeaders(headers); @@ -237,7 +242,7 @@ private static byte[] encodeHeaders(Headers headers) { } } }); - return Utils.combineByteArrays(List.of(Utils.combineByteArrays(pseudo),Utils.combineByteArrays(fields))); + return Utils.combineByteArrays(pseudo,fields); } private static byte[] encodeHeader(String name, String value) { @@ -398,6 +403,10 @@ private static byte[] indexedField(int index) { public static Integer getIndex(String name) { return STATIC_HEADER_NAME_TO_INDEX.get(name); } + + public static Collection getStaticHeaderNames() { + return STATIC_HEADER_NAME_TO_INDEX.keySet(); + } public static HTTP2HeaderField getHeaderField(int index) { if (index < 1 || index >= STATIC_HEADER_TABLE.length) { diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/HeaderFields.java b/src/main/java/robaho/net/httpserver/http2/hpack/HeaderFields.java index 58ccaed..fc61e56 100644 --- a/src/main/java/robaho/net/httpserver/http2/hpack/HeaderFields.java +++ b/src/main/java/robaho/net/httpserver/http2/hpack/HeaderFields.java @@ -3,8 +3,8 @@ import java.util.ArrayList; import java.util.Iterator; import java.util.List; -import java.util.Set; +import robaho.net.httpserver.BloomSet; import robaho.net.httpserver.OpenAddressMap; import robaho.net.httpserver.http2.HTTP2ErrorCode; import robaho.net.httpserver.http2.HTTP2Exception; @@ -15,9 +15,9 @@ * design */ public class HeaderFields implements Iterable { - private static final Set prohibitedHeaderFields = Set.of("connection"); - private static final Set requiredHeaderFields = Set.of(":path",":method",":scheme"); - private static final Set pseudoHeadersIn = Set.of(":authority", ":method", ":path", ":scheme"); + private static final BloomSet prohibitedHeaderFields = BloomSet.of("connection"); + private static final BloomSet requiredHeaderFields = BloomSet.of(":path",":method",":scheme"); + private static final BloomSet pseudoHeadersIn = BloomSet.of(":authority", ":method", ":path", ":scheme"); private final List fields = new ArrayList(); private final OpenAddressMap pseudoHeaders = new OpenAddressMap(8); @@ -70,7 +70,7 @@ public void clear() { * perform the multi-field validation of the collection of header fields */ public void validate() throws HTTP2Exception { - for(var fieldName : requiredHeaderFields) { + for(var fieldName : requiredHeaderFields.values()) { var ph = pseudoHeaders.get(fieldName); if(ph==null || isEmpty(((HTTP2HeaderField)ph).getValue())) { throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"missing required header field "+fieldName); diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java b/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java index 82b0b3a..740fc5a 100644 --- a/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java +++ b/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java @@ -6,13 +6,13 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.net.URISyntaxException; +import java.util.Arrays; -import robaho.net.httpserver.OpenAddressMap; import robaho.net.httpserver.http2.HTTP2ErrorCode; import robaho.net.httpserver.http2.HTTP2Exception; public class Huffman { - private static class HuffmanSequence { + private static class HuffmanSequence implements Comparable { private final char[] buffer; private final int length; private final int hash; @@ -27,7 +27,7 @@ private static class HuffmanSequence { private int calculateHash() { int hash = 0; for (int i = 0; i < length; i++) { - hash = 31 * hash + buffer[i]; + hash = 31 * hash + (buffer[i] == '1' ? 1 : 0); } return hash; } @@ -40,23 +40,80 @@ public boolean equals(Object obj) { if (!(obj instanceof HuffmanSequence other)) { return false; } - if (length != other.length || hash != other.hash) { + if (hash != other.hash) { return false; } + return compareTo(other) == 0; + } + @Override + public String toString() { + return new String(buffer, 0, length); + } + @Override + public int compareTo(HuffmanSequence o) { + if (length != o.length) { + return length - o.length; + } for (int i = 0; i < length; i++) { - if (buffer[i] != other.buffer[i]) { - return false; + if (buffer[i] != o.buffer[i]) { + return buffer[i] - o.buffer[i]; } } - return true; + return 0; + } + } + private static class HuffmanCode { + private final HuffmanSequence sequence; + private final int value; + HuffmanCode(HuffmanSequence sequence, int value) { + this.sequence = sequence; + this.value = value; + } + } + private static class HuffmanCodes { + private final HuffmanCode[] codes; + private final int[] offsets = new int[33]; + + HuffmanCodes(HuffmanCode[] codes) { + this.codes = codes; + Arrays.sort(codes, (a, b) -> a.sequence.compareTo(b.sequence)); + Arrays.fill(offsets,-1); + for (int i = 0; i < codes.length; i++) { + HuffmanSequence sequence = codes[i].sequence; + int length = sequence.length; + if (length <= 32 && offsets[length] == -1) { + offsets[length] = i; + } + } + } + Integer get(HuffmanSequence sequence) { + int index = offsets[sequence.length]; + if (index == -1) { + return null; + } + while(index+8 < codes.length && codes[index+8].sequence.compareTo(sequence)<0) { + index+=8; + } + while(index+4 < codes.length && codes[index+4].sequence.compareTo(sequence)<0) { + index+=4; + } + int result=0; + while(index < codes.length && (result = codes[index].sequence.compareTo(sequence))<0) { + index++; + } + if(index < codes.length && result==0) { + return codes[index].value; + } else { + return null; + } } } - static OpenAddressMap huffmanCodes = null; - public static OpenAddressMap getHuffmanCodes() + private static HuffmanCodes huffmanCodes; + private static HuffmanCodes getHuffmanCodes() throws FileNotFoundException, IOException, URISyntaxException { if (huffmanCodes == null) { - huffmanCodes = new OpenAddressMap<>(512); + HuffmanCode[] codes = new HuffmanCode[257]; ClassLoader classloader = Thread.currentThread().getContextClassLoader(); InputStream is = classloader.getResourceAsStream("huffman_codes_rfc7541.txt"); @@ -68,10 +125,11 @@ public static OpenAddressMap getHuffmanCodes() while ((line = br.readLine()) != null) { code = line.substring(11, line.indexOf(' ', 11)); code = code.replace("|", ""); - huffmanCodes.put(new HuffmanSequence(code.toCharArray()), value); + codes[value] = new HuffmanCode(new HuffmanSequence(code.toCharArray()), value); value++; } } + huffmanCodes = new HuffmanCodes(codes); } return huffmanCodes; @@ -80,7 +138,7 @@ public static OpenAddressMap getHuffmanCodes() public static String decode(byte[] value) throws HTTP2Exception { StringBuilder result = new StringBuilder(); - OpenAddressMap codes; + HuffmanCodes codes; try { codes = getHuffmanCodes(); } catch (IOException ex) { @@ -102,7 +160,7 @@ public static String decode(byte[] value) throws HTTP2Exception { unsignedByte = unsignedByte << 1; - Integer intValue = codes.get(code.sequence()); + var intValue = codes.get(code.sequence()); if(intValue!=null) { if(intValue==256) { throw new HTTP2Exception(HTTP2ErrorCode.COMPRESSION_ERROR,"decoded contains EOS "+code); From 4c8ac73ca42311609444c56adb28fb94db1c1530 Mon Sep 17 00:00:00 2001 From: robert engels Date: Sat, 4 Jan 2025 11:46:03 -0600 Subject: [PATCH 33/78] optimizations for hpack header encoding --- .../robaho/net/httpserver/OpenAddressMap.java | 31 ++++-- .../net/httpserver/OptimizedHeaders.java | 6 -- .../net/httpserver/http2/HTTP2Connection.java | 2 +- .../robaho/net/httpserver/http2/Utils.java | 19 ++++ .../httpserver/http2/hpack/HPackContext.java | 100 +++++++++++++----- .../net/httpserver/http2/hpack/Huffman.java | 8 ++ 6 files changed, 124 insertions(+), 42 deletions(-) diff --git a/src/main/java/robaho/net/httpserver/OpenAddressMap.java b/src/main/java/robaho/net/httpserver/OpenAddressMap.java index d50a419..bf2fe2d 100644 --- a/src/main/java/robaho/net/httpserver/OpenAddressMap.java +++ b/src/main/java/robaho/net/httpserver/OpenAddressMap.java @@ -90,17 +90,32 @@ private void resize() { public V get(K key) { int index = hash(key.hashCode()) & mask; int start = index; + // int count=0; Entry entry; - while ((entry = entries[index]) != null) { - if (entry.key.equals(key)) { - return (V)entry.value; - } - index = (index + 1) & mask; - if(index==start) { - break; + try { + while ((entry = entries[index]) != null) { + // count++; + if (entry.key.equals(key)) { + return (V)entry.value; + } + index = (index + 1) & mask; + if(index==start) { + break; + } } + return null; + } finally { + // System.out.println("count="+count); + // if(count>5) { + // for(var e : entries) { + // if(e!=null) { + // System.out.println(e.key+"="+e.value); + // } else { + // System.out.println("null"); + // } + // } + // } } - return null; } public int size() { diff --git a/src/main/java/robaho/net/httpserver/OptimizedHeaders.java b/src/main/java/robaho/net/httpserver/OptimizedHeaders.java index a893399..8248531 100644 --- a/src/main/java/robaho/net/httpserver/OptimizedHeaders.java +++ b/src/main/java/robaho/net/httpserver/OptimizedHeaders.java @@ -12,11 +12,7 @@ import com.sun.net.httpserver.Headers; -import robaho.net.httpserver.http2.hpack.HPackContext; - public class OptimizedHeaders extends Headers { - private static final BloomSet commonKeys = BloomSet.of(HPackContext.getStaticHeaderNames().stream().map(s -> (Character.toUpperCase(s.charAt(0))+s.substring(1))).toArray(String[]::new)); - private final OpenAddressMap map; public OptimizedHeaders() { super(); @@ -69,8 +65,6 @@ private static String normalize(String key) { int len = key.length(); if(len==0) return key; - if(commonKeys.contains(key)) return key; - int i=0; for(;i "ignoring null header for " + field.getName()); diff --git a/src/main/java/robaho/net/httpserver/http2/Utils.java b/src/main/java/robaho/net/httpserver/http2/Utils.java index 5cf6eb1..37c9e81 100644 --- a/src/main/java/robaho/net/httpserver/http2/Utils.java +++ b/src/main/java/robaho/net/httpserver/http2/Utils.java @@ -64,6 +64,25 @@ public static void writeBinary(OutputStream os,int input,int length) throws IOEx } private static final byte[] EMPTY = new byte[0]; + public static byte[] combineByteArrays(byte[]... blocks) { + if(blocks.length==0) return EMPTY; + if(blocks.length==1) return blocks[0]; + + int totalLength = 0; + for (byte[] block : blocks) { + totalLength += block.length; + } + + byte[] combined = new byte[totalLength]; + int offset = 0; + for (byte[] block : blocks) { + System.arraycopy(block, 0, combined, offset, block.length); + offset += block.length; + } + + return combined; + } + public static byte[] combineByteArrays(List blocks) { if(blocks.isEmpty()) return EMPTY; if(blocks.size()==1) return blocks.get(0); diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java b/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java index 3beb5bd..988c034 100644 --- a/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java +++ b/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java @@ -1,10 +1,10 @@ package robaho.net.httpserver.http2.hpack; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import robaho.net.httpserver.http2.HTTP2ErrorCode; import robaho.net.httpserver.http2.HTTP2Exception; @@ -14,6 +14,7 @@ import com.sun.net.httpserver.Headers; +import robaho.net.httpserver.OpenAddressMap; import robaho.net.httpserver.http2.frame.FrameFlag; import robaho.net.httpserver.http2.frame.FrameHeader; import robaho.net.httpserver.http2.Utils; @@ -26,10 +27,6 @@ public class HPackContext { public HPackContext() { } - public static Collection getStaticHeaderNames() { - return RFC7541Parser.getStaticHeaderNames(); - } - public HTTP2HeaderField getHeaderField(int index) { if (index > 0 && index <= 61) { return RFC7541Parser.getHeaderField(index); @@ -212,11 +209,69 @@ private int decodeLiteralFieldNeverIndexed(byte[] buffer, int index, HTTP2Header return index; } + /** this method is optimized for a server implementation and is not suitable for generic http2 hpack header encoding */ public static void writeHeaderFrame(Headers headers, OutputStream outputStream, int streamId) throws IOException { - byte[] buffer = encodeHeaders(headers); - FrameHeader.writeTo(outputStream, buffer.length,FrameType.HEADERS, END_OF_HEADERS, streamId); - outputStream.write(buffer); - // System.out.println("HPACK.writeHeaderFrame: wrote header frame, length: " + buffer.length + ", streamId: " + streamId); + ByteArrayOutputStream fields = new ByteArrayOutputStream(256); // average http headers length is 800 bytes + + // ':status' is required and the only allowed outbound pseudo headers + fields.write(encodeHeader(":status", headers.getFirst(":status"))); + + StringBuilder sb = new StringBuilder(32); + + headers.forEach((name, values) -> { + for (String value : values) { + if(name.startsWith(":")) { + continue; + } + // Headers keys are normalized to the first letter in uppercase and the rest in lowercase + // http2 keys are all lowercase + + sb.setLength(0); + sb.append(Character.toLowerCase(name.charAt(0))); + sb.append(name.substring(1)); + + byte[] header = encodeHeader(sb.toString(), value); + try { + fields.write(header); + } catch (IOException ex) { + } + } + }); + + FrameHeader.writeTo(outputStream, fields.size(),FrameType.HEADERS, END_OF_HEADERS, streamId); + fields.writeTo(outputStream); + } + + public static void writeGenericHeaderFrame(Headers headers, OutputStream outputStream, int streamId) throws IOException { + ByteArrayOutputStream pseudo = new ByteArrayOutputStream(); + ByteArrayOutputStream fields = new ByteArrayOutputStream(); + + headers.forEach((name, values) -> { + for (String value : values) { + if(name.startsWith(":")) { + // Headers keys are normalized to the first letter in uppercase and the rest in lowercase + // http2 keys are all lowercase + byte[] header = encodeHeader(Character.toLowerCase(name.charAt(0))+name.substring(1), value); + try { + pseudo.write(header); + } catch (IOException ex) { + } + } else { + // Headers keys are normalized to the first letter in uppercase and the rest in lowercase + // http2 keys are all lowercase + byte[] header = encodeHeader(Character.toLowerCase(name.charAt(0))+name.substring(1), value); + try { + fields.write(header); + } catch (IOException ex) { + } + + } + } + }); + + FrameHeader.writeTo(outputStream,pseudo.size()+fields.size(),FrameType.HEADERS, END_OF_HEADERS, streamId); + pseudo.writeTo(outputStream); + fields.writeTo(outputStream); } private static final FlagSet END_OF_HEADERS = FlagSet.of(FrameFlag.END_HEADERS); @@ -254,26 +309,24 @@ private static byte[] encodeHeader(String name, String value) { byte[] buffer = new byte[1]; buffer[0]=0x00; // Literal Header Field without Indexing + byte[] name_buffer = null; if(index!=null) { buffer = encodeIndexedField(index,4); } else { byte[] nameBytes = name.getBytes(); - byte[] header = encodeString(nameBytes); - buffer = Arrays.copyOf(buffer, buffer.length + header.length); - System.arraycopy(header, 0, buffer, buffer.length - header.length, header.length); + name_buffer = encodeString(nameBytes); } // Encode header value byte[] valueBytes = value.getBytes(); - byte[] header = encodeString(valueBytes); - buffer = Arrays.copyOf(buffer, buffer.length + header.length); - System.arraycopy(header, 0, buffer, buffer.length - header.length, header.length); + byte[] value_buffer = encodeString(valueBytes); - return buffer; + return name_buffer == null ? Utils.combineByteArrays(buffer,value_buffer) : Utils.combineByteArrays(buffer,name_buffer,value_buffer); } private static byte[] encodeString(byte[] value) { + // TODO: implement huffman encoding byte[] buffer = new byte[0]; if (value.length < 128) { buffer = Arrays.copyOf(buffer, buffer.length + 1); @@ -392,21 +445,14 @@ private static byte[] indexedField(int index) { STATIC_HEADER_TABLE[61] = new HTTP2HeaderField("www-authenticate", null); } - private static final Map STATIC_HEADER_NAME_TO_INDEX = Arrays.stream(STATIC_HEADER_TABLE) - .filter(f -> f != null) - .collect(java.util.stream.Collectors.toMap( - f -> f.name, - f -> Arrays.asList(STATIC_HEADER_TABLE).indexOf(f), - (a, b) -> a - )); + private static final OpenAddressMap STATIC_HEADER_NAME_TO_INDEX = new OpenAddressMap<>(256); + static { + Arrays.stream(STATIC_HEADER_TABLE).filter(v -> v!=null).forEach(v -> STATIC_HEADER_NAME_TO_INDEX.put(v.name, Arrays.asList(STATIC_HEADER_TABLE).indexOf(v))); + } public static Integer getIndex(String name) { return STATIC_HEADER_NAME_TO_INDEX.get(name); } - - public static Collection getStaticHeaderNames() { - return STATIC_HEADER_NAME_TO_INDEX.keySet(); - } public static HTTP2HeaderField getHeaderField(int index) { if (index < 1 || index >= STATIC_HEADER_TABLE.length) { diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java b/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java index 740fc5a..3824950 100644 --- a/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java +++ b/src/main/java/robaho/net/httpserver/http2/hpack/Huffman.java @@ -70,8 +70,15 @@ private static class HuffmanCode { this.value = value; } } + /** + * a skip list of huffman codes + */ private static class HuffmanCodes { private final HuffmanCode[] codes; + /** + * holds the offset into the array for the start of codes by the code length, or -1 if there + * are no codes of that length + */ private final int[] offsets = new int[33]; HuffmanCodes(HuffmanCode[] codes) { @@ -86,6 +93,7 @@ private static class HuffmanCodes { } } } + /** @return the matched character value or null if no match */ Integer get(HuffmanSequence sequence) { int index = offsets[sequence.length]; if (index == -1) { From f0a53c002086d2b2e34bb132a786ac45e4bcf609 Mon Sep 17 00:00:00 2001 From: robert engels Date: Sat, 4 Jan 2025 12:42:19 -0600 Subject: [PATCH 34/78] update readme --- README.md | 74 ++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 46 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 38965c0..b77c3b9 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ The frameworks were also tested using [go-wrk](https://github.com/tsliwowicz/go- 1_The robaho version has been submitted to the Tech Empower benchmarks project for 3-party confirmation._
2_`go-wrk` does not use http pipelining so, the large number of connections is the limiting factor._ -Performance tests against the latest Jetty version were run. The `robaho httpserver` outperformed the Jetty http2 by 5x in both http1 and http2. +Performance tests against the latest Jetty version were run. The `robaho httpserver` outperformed the Jetty http2 by 3x, and http1 by 5x. The Javalin/Jetty project is available [here](https://github.com/robaho/javalin-http2-example) @@ -159,15 +159,16 @@ Jetty 11 http2 starting benchmark... spawning thread #0: 16 total client(s). 1000000 total requests Application protocol: h2c -finished in 5.20s, 192421.22 req/s, 6.79MB/s +finished in 3.47s, 288284.80 req/s, 10.17MB/s requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout status codes: 1000000 2xx, 0 3xx, 0 4xx, 0 5xx -traffic: 35.29MB (37003264) total, 7.63MB (8002384) headers (space savings 90.12%), 10.49MB (11000000) data +traffic: 35.29MB (37002689) total, 7.63MB (8001809) headers (space savings 90.12%), 10.49MB (11000000) data min max mean sd +/- sd -time for request: 142us 43.73ms 7.20ms 3.96ms 70.90% -time for connect: 176us 7.70ms 3.96ms 2.34ms 62.50% -time to 1st byte: 10.48ms 20.63ms 13.65ms 2.93ms 75.00% -req/s : 12026.57 12200.62 12070.81 46.69 93.75% +time for request: 94us 381.85ms 6.42ms 21.51ms 96.90% +time for connect: 389us 5.88ms 3.15ms 1.75ms 62.50% +time to 1st byte: 6.61ms 11.74ms 7.85ms 1.24ms 87.50% +req/s : 18020.94 23235.01 19829.09 1588.94 75.00% + ``` Jetty 11 http1 @@ -175,15 +176,16 @@ Jetty 11 http1 starting benchmark... spawning thread #0: 16 total client(s). 1000000 total requests Application protocol: http/1.1 -finished in 3.86s, 258839.63 req/s, 33.82MB/s +finished in 3.63s, 275680.69 req/s, 36.02MB/s requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout -status codes: 1000000 2xx, 0 3xx, 0 4xx, 0 5xx +status codes: 1000021 2xx, 0 3xx, 0 4xx, 0 5xx traffic: 130.65MB (137000000) total, 86.78MB (91000000) headers (space savings 0.00%), 10.49MB (11000000) data min max mean sd +/- sd -time for request: 1.52ms 194.72ms 60.42ms 21.40ms 74.16% -time for connect: 172us 4.07ms 2.13ms 1.21ms 62.50% -time to 1st byte: 4.70ms 10.80ms 6.66ms 1.96ms 87.50% -req/s : 16178.98 16976.90 16456.91 175.54 81.25% +time for request: 1.59ms 336.00ms 53.17ms 51.56ms 85.36% +time for connect: 422us 2.57ms 1.54ms 632us 62.50% +time to 1st byte: 2.98ms 314.97ms 26.14ms 77.12ms 93.75% +req/s : 17232.15 21230.14 18780.35 1130.32 68.75 + ``` robaho http2 @@ -191,15 +193,15 @@ robaho http2 starting benchmark... spawning thread #0: 16 total client(s). 1000000 total requests Application protocol: h2c -finished in 1.08s, 927732.43 req/s, 38.93MB/s +finished in 1.03s, 966710.36 req/s, 40.57MB/s requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout status codes: 1000000 2xx, 0 3xx, 0 4xx, 0 5xx traffic: 41.96MB (44000480) total, 5.72MB (6000000) headers (space savings 76.92%), 10.49MB (11000000) data min max mean sd +/- sd -time for request: 226us 84.23ms 15.51ms 9.23ms 77.11% -time for connect: 521us 5.57ms 3.13ms 1.57ms 62.50% -time to 1st byte: 6.46ms 17.15ms 10.12ms 3.82ms 81.25% -req/s : 58012.46 66943.10 60509.05 2819.65 87.50% +time for request: 457us 71.41ms 14.71ms 8.63ms 73.09% +time for connect: 336us 5.77ms 3.13ms 1.73ms 62.50% +time to 1st byte: 6.59ms 15.30ms 10.40ms 3.32ms 50.00% +req/s : 60461.71 66800.04 62509.79 1544.65 75.00% ``` robaho http1 @@ -207,15 +209,15 @@ robaho http1 starting benchmark... spawning thread #0: 16 total client(s). 1000000 total requests Application protocol: http/1.1 -finished in 784.26ms, 1275080.84 req/s, 105.79MB/s +finished in 776.64ms, 1287592.88 req/s, 106.83MB/s requests: 1000000 total, 1000000 started, 1000000 done, 1000000 succeeded, 0 failed, 0 errored, 0 timeout -status codes: 1001125 2xx, 0 3xx, 0 4xx, 0 5xx +status codes: 1000123 2xx, 0 3xx, 0 4xx, 0 5xx traffic: 82.97MB (87000000) total, 46.73MB (49000000) headers (space savings 0.00%), 10.49MB (11000000) data min max mean sd +/- sd -time for request: 763us 26.87ms 12.34ms 2.71ms 74.28% -time for connect: 104us 4.32ms 2.23ms 1.30ms 62.50% -time to 1st byte: 4.91ms 16.21ms 10.36ms 4.49ms 43.75% -req/s : 79744.46 81149.46 80228.21 355.56 75.00% +time for request: 376us 380.30ms 9.12ms 32.43ms 99.20% +time for connect: 240us 2.51ms 1.50ms 720us 62.50% +time to 1st byte: 3.04ms 18.85ms 8.93ms 5.77ms 68.75% +req/s : 80530.13 167605.46 122588.82 42385.59 87.50% ``` @@ -248,7 +250,7 @@ The counts can be reset using `/__stats?reset`. The `requests/sec` is calculated io.github.robaho httpserver - 1.0.16 + 1.0.17 ``` ## enable Http2 @@ -263,10 +265,26 @@ See the additional Http2 options in `ServerConfig.java` The http2 implementation passes all specification tests in [h2spec](https://github.com/summerwind/h2spec) -## Http2 performance notes +## performance notes + +Http2 performance has not been fully optimized. The http2 version is about 20-30% slower than http1. I expect this to be the case with most http2 implementations due to the complexity. +http2 outperforms http1 when sending multiple simultaneous requests from the client with payloads, as most servers and clients do not implement http pipelining when payloads are involved. + +TODO: sending hpack headers does not use huffman encoding or dynamic table management. see the following paper https://www.mew.org/~kazu/doc/paper/hpack-2017.pdf for optimizing the implementation further. -Http2 performance has not been fully optimized - there is room for improvement. +The most expensive operations involve converting strings to URI instances. Unfortunately, since using URI is part of the server specification little can be done in this regard. +It could be instantiated lazily, but almost all handlers need access to elements. + +The standard JDK Headers implementation normalizes all headers by ensuring the first character is a capital letter, and the rest lowercase. A better solution would be to use all lowercase to match http2, so less conversions would be required. The scheme is also more complex than needs to be. So, Handler code should be written using the same scheme: + +```java +var value = request.getFirst("Content-length"); + +vs + +var value = request.getFirst("content-length"); +``` -The http2 version is about 20% slower than http1. I expect this to be the case with most http2 implementations due to the complexity. +for optimal performance. From 7d8cea2719dec2121c6fe7932bb52be419e317dd Mon Sep 17 00:00:00 2001 From: robert engels Date: Sat, 4 Jan 2025 20:51:10 -0600 Subject: [PATCH 35/78] Update README.md --- README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/README.md b/README.md index b77c3b9..d0a8e07 100644 --- a/README.md +++ b/README.md @@ -42,9 +42,7 @@ or the service loader will automatically find it when the jar is placed on the c ## performance -** updated 11/22/2024: retested using JDK 23. The results for the JDK version dropped dramatically because I was able to resolve the source of the errors (incorrect network configuration) - and now the robaho version is more than 10x faster. - -This version performs more than **10x** better than the JDK version when tested using the [Tech Empower Benchmarks](https://github.com/TechEmpower/FrameworkBenchmarks/tree/master/frameworks/Java/httpserver) on an identical hardware/work setup with the same JDK 23 version.1 +This verson performs more than **10x** better than the JDK version when tested using the [Tech Empower Benchmarks](https://github.com/TechEmpower/FrameworkBenchmarks/tree/master/frameworks/Java/httpserver) on an identical hardware/work setup with the same JDK 23 version.1 The frameworks were also tested using [go-wrk](https://github.com/tsliwowicz/go-wrk)2 From c9857e8e1460dad221babb71bf608a9a8c347f19 Mon Sep 17 00:00:00 2001 From: robert engels Date: Tue, 7 Jan 2025 11:52:02 -0600 Subject: [PATCH 36/78] use MIT license for original code --- LICENSE | 360 ++++---------------------------------------------------- 1 file changed, 21 insertions(+), 339 deletions(-) diff --git a/LICENSE b/LICENSE index 6912f5e..59b3934 100644 --- a/LICENSE +++ b/LICENSE @@ -1,342 +1,24 @@ NOTE: The source code in this project is covered under multiple licenses. The licenses are contained in the source files. This license applies where applicable to any solely original code. - GNU GENERAL PUBLIC LICENSE - Version 2, June 1991 - - Copyright (C) 1989, 1991 Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The licenses for most software are designed to take away your -freedom to share and change it. By contrast, the GNU General Public -License is intended to guarantee your freedom to share and change free -software--to make sure the software is free for all its users. This -General Public License applies to most of the Free Software -Foundation's software and to any other program whose authors commit to -using it. (Some other Free Software Foundation software is covered by -the GNU Lesser General Public License instead.) You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -this service if you wish), that you receive source code or can get it -if you want it, that you can change the software or use pieces of it -in new free programs; and that you know you can do these things. - - To protect your rights, we need to make restrictions that forbid -anyone to deny you these rights or to ask you to surrender the rights. -These restrictions translate to certain responsibilities for you if you -distribute copies of the software, or if you modify it. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must give the recipients all the rights that -you have. You must make sure that they, too, receive or can get the -source code. And you must show them these terms so they know their -rights. - - We protect your rights with two steps: (1) copyright the software, and -(2) offer you this license which gives you legal permission to copy, -distribute and/or modify the software. - - Also, for each author's protection and ours, we want to make certain -that everyone understands that there is no warranty for this free -software. If the software is modified by someone else and passed on, we -want its recipients to know that what they have is not the original, so -that any problems introduced by others will not reflect on the original -authors' reputations. - - Finally, any free program is threatened constantly by software -patents. We wish to avoid the danger that redistributors of a free -program will individually obtain patent licenses, in effect making the -program proprietary. To prevent this, we have made it clear that any -patent must be licensed for everyone's free use or not licensed at all. - - The precise terms and conditions for copying, distribution and -modification follow. - - GNU GENERAL PUBLIC LICENSE - TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - - 0. This License applies to any program or other work which contains -a notice placed by the copyright holder saying it may be distributed -under the terms of this General Public License. The "Program", below, -refers to any such program or work, and a "work based on the Program" -means either the Program or any derivative work under copyright law: -that is to say, a work containing the Program or a portion of it, -either verbatim or with modifications and/or translated into another -language. (Hereinafter, translation is included without limitation in -the term "modification".) Each licensee is addressed as "you". - -Activities other than copying, distribution and modification are not -covered by this License; they are outside its scope. The act of -running the Program is not restricted, and the output from the Program -is covered only if its contents constitute a work based on the -Program (independent of having been made by running the Program). -Whether that is true depends on what the Program does. - - 1. You may copy and distribute verbatim copies of the Program's -source code as you receive it, in any medium, provided that you -conspicuously and appropriately publish on each copy an appropriate -copyright notice and disclaimer of warranty; keep intact all the -notices that refer to this License and to the absence of any warranty; -and give any other recipients of the Program a copy of this License -along with the Program. - -You may charge a fee for the physical act of transferring a copy, and -you may at your option offer warranty protection in exchange for a fee. - - 2. You may modify your copy or copies of the Program or any portion -of it, thus forming a work based on the Program, and copy and -distribute such modifications or work under the terms of Section 1 -above, provided that you also meet all of these conditions: - - a) You must cause the modified files to carry prominent notices - stating that you changed the files and the date of any change. - - b) You must cause any work that you distribute or publish, that in - whole or in part contains or is derived from the Program or any - part thereof, to be licensed as a whole at no charge to all third - parties under the terms of this License. - - c) If the modified program normally reads commands interactively - when run, you must cause it, when started running for such - interactive use in the most ordinary way, to print or display an - announcement including an appropriate copyright notice and a - notice that there is no warranty (or else, saying that you provide - a warranty) and that users may redistribute the program under - these conditions, and telling the user how to view a copy of this - License. (Exception: if the Program itself is interactive but - does not normally print such an announcement, your work based on - the Program is not required to print an announcement.) - -These requirements apply to the modified work as a whole. If -identifiable sections of that work are not derived from the Program, -and can be reasonably considered independent and separate works in -themselves, then this License, and its terms, do not apply to those -sections when you distribute them as separate works. But when you -distribute the same sections as part of a whole which is a work based -on the Program, the distribution of the whole must be on the terms of -this License, whose permissions for other licensees extend to the -entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest -your rights to work written entirely by you; rather, the intent is to -exercise the right to control the distribution of derivative or -collective works based on the Program. - -In addition, mere aggregation of another work not based on the Program -with the Program (or with a work based on the Program) on a volume of -a storage or distribution medium does not bring the other work under -the scope of this License. - - 3. You may copy and distribute the Program (or a work based on it, -under Section 2) in object code or executable form under the terms of -Sections 1 and 2 above provided that you also do one of the following: - - a) Accompany it with the complete corresponding machine-readable - source code, which must be distributed under the terms of Sections - 1 and 2 above on a medium customarily used for software interchange; or, - - b) Accompany it with a written offer, valid for at least three - years, to give any third party, for a charge no more than your - cost of physically performing source distribution, a complete - machine-readable copy of the corresponding source code, to be - distributed under the terms of Sections 1 and 2 above on a medium - customarily used for software interchange; or, - - c) Accompany it with the information you received as to the offer - to distribute corresponding source code. (This alternative is - allowed only for noncommercial distribution and only if you - received the program in object code or executable form with such - an offer, in accord with Subsection b above.) - -The source code for a work means the preferred form of the work for -making modifications to it. For an executable work, complete source -code means all the source code for all modules it contains, plus any -associated interface definition files, plus the scripts used to -control compilation and installation of the executable. However, as a -special exception, the source code distributed need not include -anything that is normally distributed (in either source or binary -form) with the major components (compiler, kernel, and so on) of the -operating system on which the executable runs, unless that component -itself accompanies the executable. - -If distribution of executable or object code is made by offering -access to copy from a designated place, then offering equivalent -access to copy the source code from the same place counts as -distribution of the source code, even though third parties are not -compelled to copy the source along with the object code. - - 4. You may not copy, modify, sublicense, or distribute the Program -except as expressly provided under this License. Any attempt -otherwise to copy, modify, sublicense or distribute the Program is -void, and will automatically terminate your rights under this License. -However, parties who have received copies, or rights, from you under -this License will not have their licenses terminated so long as such -parties remain in full compliance. - - 5. You are not required to accept this License, since you have not -signed it. However, nothing else grants you permission to modify or -distribute the Program or its derivative works. These actions are -prohibited by law if you do not accept this License. Therefore, by -modifying or distributing the Program (or any work based on the -Program), you indicate your acceptance of this License to do so, and -all its terms and conditions for copying, distributing or modifying -the Program or works based on it. - - 6. Each time you redistribute the Program (or any work based on the -Program), the recipient automatically receives a license from the -original licensor to copy, distribute or modify the Program subject to -these terms and conditions. You may not impose any further -restrictions on the recipients' exercise of the rights granted herein. -You are not responsible for enforcing compliance by third parties to -this License. - - 7. If, as a consequence of a court judgment or allegation of patent -infringement or for any other reason (not limited to patent issues), -conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot -distribute so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you -may not distribute the Program at all. For example, if a patent -license would not permit royalty-free redistribution of the Program by -all those who receive copies directly or indirectly through you, then -the only way you could satisfy both it and this License would be to -refrain entirely from distribution of the Program. - -If any portion of this section is held invalid or unenforceable under -any particular circumstance, the balance of the section is intended to -apply and the section as a whole is intended to apply in other -circumstances. - -It is not the purpose of this section to induce you to infringe any -patents or other property right claims or to contest validity of any -such claims; this section has the sole purpose of protecting the -integrity of the free software distribution system, which is -implemented by public license practices. Many people have made -generous contributions to the wide range of software distributed -through that system in reliance on consistent application of that -system; it is up to the author/donor to decide if he or she is willing -to distribute software through any other system and a licensee cannot -impose that choice. - -This section is intended to make thoroughly clear what is believed to -be a consequence of the rest of this License. - - 8. If the distribution and/or use of the Program is restricted in -certain countries either by patents or by copyrighted interfaces, the -original copyright holder who places the Program under this License -may add an explicit geographical distribution limitation excluding -those countries, so that distribution is permitted only in or among -countries not thus excluded. In such case, this License incorporates -the limitation as if written in the body of this License. - - 9. The Free Software Foundation may publish revised and/or new versions -of the General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - -Each version is given a distinguishing version number. If the Program -specifies a version number of this License which applies to it and "any -later version", you have the option of following the terms and conditions -either of that version or of any later version published by the Free -Software Foundation. If the Program does not specify a version number of -this License, you may choose any version ever published by the Free Software -Foundation. - - 10. If you wish to incorporate parts of the Program into other free -programs whose distribution conditions are different, write to the author -to ask for permission. For software which is copyrighted by the Free -Software Foundation, write to the Free Software Foundation; we sometimes -make exceptions for this. Our decision will be guided by the two goals -of preserving the free status of all derivatives of our free software and -of promoting the sharing and reuse of software generally. - - NO WARRANTY - - 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY -FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN -OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES -PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED -OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS -TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE -PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, -REPAIR OR CORRECTION. - - 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR -REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, -INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING -OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED -TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY -YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER -PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE -POSSIBILITY OF SUCH DAMAGES. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -convey the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation; either version 2 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License along - with this program; if not, write to the Free Software Foundation, Inc., - 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. - -Also add information on how to contact you by electronic and paper mail. - -If the program is interactive, make it output a short notice like this -when it starts in an interactive mode: - - Gnomovision version 69, Copyright (C) year name of author - Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, the commands you use may -be called something other than `show w' and `show c'; they could even be -mouse-clicks or menu items--whatever suits your program. - -You should also get your employer (if you work as a programmer) or your -school, if any, to sign a "copyright disclaimer" for the program, if -necessary. Here is a sample; alter the names: - - Yoyodyne, Inc., hereby disclaims all copyright interest in the program - `Gnomovision' (which makes passes at compilers) written by James Hacker. - - , 1 April 1989 - Ty Coon, President of Vice - -This General Public License does not permit incorporating your program into -proprietary programs. If your program is a subroutine library, you may -consider it more useful to permit linking proprietary applications with the -library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. +MIT License + +Copyright (c) 2024 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file From ad695557fd5d4f1d7006cacaecb628dadc9d8152 Mon Sep 17 00:00:00 2001 From: robert engels Date: Tue, 7 Jan 2025 11:52:34 -0600 Subject: [PATCH 37/78] add Dockerfile for testing --- Dockerfile | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 Dockerfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..26e8542 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# Use OpenJDK 23 +FROM openjdk:23-slim + +# Set working directory +WORKDIR /app + +# Install curl +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +# Copy the application files +COPY build/libs/httpserver.jar . +COPY build/libs/httpserver-test.jar . + +RUN mkdir -p /app/fileserver + +# Expose the port your application runs on +EXPOSE 8080 + +# Command to run the application +# Replace "YourApplication.jar" with your actual JAR file name +CMD ["java", "-cp", "httpserver-test.jar:httpserver.jar", "SimpleFileServer","fileserver","8080","fileserver/logfile.txt" ] \ No newline at end of file From 800166c1b4486ed4a1c87de636595fd9937bdacb Mon Sep 17 00:00:00 2001 From: robert engels Date: Wed, 8 Jan 2025 23:21:49 -0600 Subject: [PATCH 38/78] Add server statistics tracking and improve README clarity - Introduced ServerStats and HTTP2Stats classes for tracking connection and request statistics. - Updated README to clarify statistics reset behavior. - Added debug logging method in HttpConnection for better request tracking. - Enhanced ServerConfig with new HTTP/2 connection window size configuration. - properly support http2 receive window --- README.md | 2 +- .../robaho/net/httpserver/HttpConnection.java | 6 +- .../robaho/net/httpserver/ServerConfig.java | 6 + .../robaho/net/httpserver/ServerImpl.java | 115 ++++---- .../robaho/net/httpserver/ServerStats.java | 33 +++ .../net/httpserver/http2/HTTP2Connection.java | 45 ++- .../net/httpserver/http2/HTTP2Stats.java | 39 +++ .../net/httpserver/http2/HTTP2Stream.java | 279 ++++++++---------- 8 files changed, 291 insertions(+), 234 deletions(-) create mode 100644 src/main/java/robaho/net/httpserver/ServerStats.java create mode 100644 src/main/java/robaho/net/httpserver/http2/HTTP2Stats.java diff --git a/README.md b/README.md index d0a8e07..0941118 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ Idle Closes: 0 Reply Errors: 0 ``` -The counts can be reset using `/__stats?reset`. The `requests/sec` is calculated from the previous statistics request. +The counts and rates for non "Total" statistics are reset with each pull of the statistics. ## maven diff --git a/src/main/java/robaho/net/httpserver/HttpConnection.java b/src/main/java/robaho/net/httpserver/HttpConnection.java index a13e786..e7f1a9e 100644 --- a/src/main/java/robaho/net/httpserver/HttpConnection.java +++ b/src/main/java/robaho/net/httpserver/HttpConnection.java @@ -74,6 +74,10 @@ public boolean isClosed() { connectionId = "["+socket.getLocalPort()+"."+socket.getPort()+"]"; } + public void debug() { + logger.log(Level.INFO,toString()+", inRequest "+inRequest+", request count "+requestCount.get()); + } + SSLSession getSSLSession() { return (socket instanceof SSLSocket ssl) ? ssl.getHandshakeSession() : null; } @@ -113,7 +117,7 @@ synchronized void close() { if(requestCount.get()==0) { logger.log(Level.WARNING, "closing connection: remote "+socket.getRemoteSocketAddress() + " with 0 requests"); } else { - logger.log(Level.TRACE, () -> "Closing connection: remote " + socket.getRemoteSocketAddress()); + logger.log(Level.TRACE, () -> "closing connection: remote " + socket.getRemoteSocketAddress()); } } diff --git a/src/main/java/robaho/net/httpserver/ServerConfig.java b/src/main/java/robaho/net/httpserver/ServerConfig.java index b7fd694..a53d653 100644 --- a/src/main/java/robaho/net/httpserver/ServerConfig.java +++ b/src/main/java/robaho/net/httpserver/ServerConfig.java @@ -52,6 +52,7 @@ public class ServerConfig { private static final int DEFAULT_HTTP2_MAX_FRAME_SIZE = 16384; private static final int DEFAULT_HTTP2_INITIAL_WINDOW_SIZE = 65535; + private static final int DEFAULT_HTTP2_CONNECTION_WINDOW_SIZE = 65535; private static final int DEFAULT_HTTP2_MAX_CONCURRENT_STREAMS = -1; // use -1 for no limit private static long idleTimerScheduleMillis; @@ -79,6 +80,7 @@ public class ServerConfig { private static boolean http2OverNonSSL; private static int http2MaxFrameSize; private static int http2InitialWindowSize; + private static int http2ConnectionWindowSize; private static int http2MaxConcurrentStreams; private static boolean http2DisableFlushDelay; @@ -142,6 +144,7 @@ public Void run() { http2MaxFrameSize = Integer.getInteger(pkg + ".http2MaxFrameSize", DEFAULT_HTTP2_MAX_FRAME_SIZE); http2InitialWindowSize = Integer.getInteger(pkg + ".http2InitialWindowSize", DEFAULT_HTTP2_INITIAL_WINDOW_SIZE); + http2ConnectionWindowSize = Integer.getInteger(pkg + ".http2ConnectionWindowSize", DEFAULT_HTTP2_CONNECTION_WINDOW_SIZE); http2MaxConcurrentStreams = Integer.getInteger(pkg + ".http2MaxConcurrentStreams", DEFAULT_HTTP2_MAX_CONCURRENT_STREAMS); http2DisableFlushDelay = Boolean.getBoolean(pkg + ".http2DisableFlushDelay"); @@ -249,6 +252,9 @@ public static int http2MaxFrameSize() { public static int http2InitialWindowSize() { return http2InitialWindowSize; } + public static int http2ConnectionWindowSize() { + return http2ConnectionWindowSize; + } /** * @return the maximum number of concurrent streams per connection, or -1 for no limit */ diff --git a/src/main/java/robaho/net/httpserver/ServerImpl.java b/src/main/java/robaho/net/httpserver/ServerImpl.java index fce1c6a..7c7dc86 100644 --- a/src/main/java/robaho/net/httpserver/ServerImpl.java +++ b/src/main/java/robaho/net/httpserver/ServerImpl.java @@ -51,7 +51,6 @@ import java.util.concurrent.Executor; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicLong; import java.util.logging.LogRecord; import javax.net.ssl.SSLSocket; @@ -68,6 +67,7 @@ import robaho.net.httpserver.http2.HTTP2Connection; import robaho.net.httpserver.http2.HTTP2ErrorCode; import robaho.net.httpserver.http2.HTTP2Exception; +import robaho.net.httpserver.http2.HTTP2Stats; import robaho.net.httpserver.http2.HTTP2Stream; /** @@ -119,13 +119,8 @@ class ServerImpl { private Thread dispatcherThread; // statistics - private final AtomicLong connectionCount = new AtomicLong(); - private final AtomicLong requestCount = new AtomicLong(); - private final AtomicLong handleExceptionCount = new AtomicLong(); - private final AtomicLong socketExceptionCount = new AtomicLong(); - private final AtomicLong idleCloseCount = new AtomicLong(); - private final AtomicLong replyErrorCount = new AtomicLong(); - private final AtomicLong maxConnectionsExceededCount = new AtomicLong(); + private final ServerStats stats = new ServerStats(); + private final HTTP2Stats http2Stats = new HTTP2Stats(); ServerImpl(HttpServer wrapper, String protocol, InetSocketAddress addr, int backlog) throws IOException { @@ -156,55 +151,41 @@ class ServerImpl { if(Boolean.getBoolean("robaho.net.httpserver.EnableStats")) { createContext("/__stats",new StatsHandler()); } + if(Boolean.getBoolean("robaho.net.httpserver.EnableDebug")) { + createContext("/__debug",new DebugHandler()); + } } private class StatsHandler implements HttpHandler { - volatile long lastStatsTime = System.currentTimeMillis(); - volatile long lastRequestCount = 0; @Override public void handle(HttpExchange exchange) throws IOException { - - long now = System.currentTimeMillis(); - - if("reset".equals(exchange.getRequestURI().getQuery())) { - connectionCount.set(0); - requestCount.set(0); - handleExceptionCount.set(0); - socketExceptionCount.set(0); - idleCloseCount.set(0); - replyErrorCount.set(0); - maxConnectionsExceededCount.set(0); - lastStatsTime = now; - lastRequestCount = 0; - exchange.sendResponseHeaders(200,-1); - exchange.close(); - return; - } - - var rc = requestCount.get(); - var output = ( - "Connections: "+connectionCount.get()+"\n" + "Active Connections: "+allConnections.size()+"\n" + - "Requests: "+rc+"\n" + - "Requests/sec: "+(long)((rc-lastRequestCount)/(((double)(now-lastStatsTime))/1000))+"\n"+ - "Handler Exceptions: "+handleExceptionCount.get()+"\n"+ - "Socket Exceptions: "+socketExceptionCount.get()+"\n"+ - "Mac Connections Exceeded: "+maxConnectionsExceededCount.get()+"\n"+ - "Idle Closes: "+idleCloseCount.get()+"\n"+ - "Reply Errors: "+replyErrorCount.get()+"\n" + stats.stats()+ + http2Stats.stats() ).getBytes(); - lastStatsTime = now; - lastRequestCount = rc; - exchange.sendResponseHeaders(200,output.length); exchange.getResponseBody().write(output); exchange.getResponseBody().close(); } } + /** log state to assist debugging */ + private class DebugHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + logger.log(Level.INFO,"logging debug state, requestor "+exchange.getRemoteAddress()); + for(var connection : allConnections) { + connection.debug(); + } + Http2Exchange.debug(); + exchange.sendResponseHeaders(200,-1); + } + } + + public void bind(InetSocketAddress addr, int backlog) throws IOException { if (bound) { throw new BindException("HttpServer already bound"); @@ -365,12 +346,12 @@ public void run() { if(logger.isLoggable(Level.TRACE)) { logger.log(Level.TRACE, "accepted connection: " + s.toString()); } - connectionCount.incrementAndGet(); + 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 { - maxConnectionsExceededCount.incrementAndGet(); + stats.maxConnectionsExceededCount.incrementAndGet(); logger.log(Level.WARNING, "closing accepted connection due to too many connections"); s.close(); } catch (IOException ignore) { @@ -429,7 +410,7 @@ public void run() { } catch (Exception e) { logger.log(Level.TRACE, "Dispatcher Exception", e); - handleExceptionCount.incrementAndGet(); + stats.handleExceptionCount.incrementAndGet(); closeConnection(c); } } catch (IOException e) { @@ -473,12 +454,23 @@ class Http2Exchange implements Runnable,HTTP2Connection.StreamHandler { final String protocol; private static final Set allHttp2Exchanges = Collections.newSetFromMap(new ConcurrentHashMap<>()); + static void debug() { + for(var exchange : allHttp2Exchanges) { + exchange.http2.debug(); + } + } Http2Exchange(String protocol, HttpConnection conn) throws IOException { this.connection = conn; this.protocol = protocol; - http2 = new HTTP2Connection(conn, connection.getInputStream(), connection.getOutputStream(), this); + if(protocol.equals("https2")) { + http2Stats.sslConnections.incrementAndGet(); + } else { + http2Stats.nonsslConnections.incrementAndGet(); + } + + http2 = new HTTP2Connection(conn,http2Stats,connection.getInputStream(), connection.getOutputStream(), this); } static TimerTask createTask() { @@ -512,7 +504,8 @@ public void run() { http2.handle(); } catch (HTTP2Exception ex) { logger.log(Level.WARNING, "ServerImpl http2 protocol exception "+http2,ex); - } catch (EOFException | SocketException ex) { + } catch (IOException ex) { + stats.socketExceptionCount.incrementAndGet(); logger.log(Level.DEBUG, "end of stream "+http2); } catch (Exception ex) { logger.log(Level.WARNING, "ServerImpl unexpected exception handling http2 connection "+http2, ex); @@ -536,7 +529,9 @@ public Executor getExecutor() { @Override public void handleStream(HTTP2Stream stream,InputStream in, OutputStream out) throws IOException { connection.requestCount.incrementAndGet(); - requestCount.incrementAndGet(); + stats.requestCount.incrementAndGet(); + + http2Stats.totalStreams.incrementAndGet(); var request = stream.getRequestHeaders(); var response = stream.getResponseHeaders(); @@ -584,7 +579,7 @@ public void handleStream(HTTP2Stream stream,InputStream in, OutputStream out) th return; } - logger.log(Level.DEBUG,() -> "http2 request on "+connection+" "+method+" for "+uri); + logger.log(Level.TRACE,() -> "http2 request on "+connection+" "+method+" for "+uri); final List sf = ctx.getSystemFilters(); final List uf = ctx.getFilters(); @@ -592,10 +587,16 @@ public void handleStream(HTTP2Stream stream,InputStream in, OutputStream out) th final Filter.Chain sc = new Filter.Chain(sf, ctx.getHandler()); final Filter.Chain uc = new Filter.Chain(uf, new LinkHandler(sc)); - if (https) { - uc.doFilter(new Http2ExchangeImpl(stream,uri,method,ctx,request,response,in,out)); - } else { - uc.doFilter(new Http2ExchangeImpl(stream,uri,method,ctx,request,response,in,out)); + try { + if (https) { + uc.doFilter(new Http2ExchangeImpl(stream,uri,method,ctx,request,response,in,out)); + } else { + uc.doFilter(new Http2ExchangeImpl(stream,uri,method,ctx,request,response,in,out)); + } + } catch (IOException e) { + } catch (Exception e) { + logger.log(Level.WARNING, "Dispatcher Exception on "+stream, e); + stats.handleExceptionCount.incrementAndGet(); } } } @@ -630,7 +631,7 @@ public void run() { } catch (SocketException e) { // these are common with clients breaking connections etc logger.log(Level.TRACE, "ServerImpl IOException", e); - socketExceptionCount.incrementAndGet(); + stats.socketExceptionCount.incrementAndGet(); closeConnection(connection); break; } catch (Exception e) { @@ -675,14 +676,14 @@ private void runPerRequest() throws IOException { connection.inRequest = true; - if (requestLine == null) { + if (requestLine == null || "".equals(requestLine)) { /* connection closed */ logger.log(Level.DEBUG, "no request line: closing"); closeConnection(connection); return; } connection.requestCount.incrementAndGet(); - requestCount.incrementAndGet(); + stats.requestCount.incrementAndGet(); logger.log(Level.DEBUG, () -> "Exchange request line: "+ requestLine); int space = requestLine.indexOf(" "); @@ -870,7 +871,7 @@ void sendReply( } } catch (IOException e) { logger.log(Level.TRACE, "ServerImpl.sendReply", e); - replyErrorCount.incrementAndGet(); + stats.replyErrorCount.incrementAndGet(); closeConnection(connection); } } @@ -915,7 +916,7 @@ public void run() { for (var c : allConnections) { if (now- c.lastActivityTime >= IDLE_INTERVAL && !c.inRequest) { logger.log(Level.DEBUG, "closing idle connection"); - idleCloseCount.incrementAndGet(); + stats.idleCloseCount.incrementAndGet(); closeConnection(c); // idle.add(c); } else if (c.noActivity && (now - c.lastActivityTime >= NEWLY_ACCEPTED_CONN_IDLE_INTERVAL)) { diff --git a/src/main/java/robaho/net/httpserver/ServerStats.java b/src/main/java/robaho/net/httpserver/ServerStats.java new file mode 100644 index 0000000..678fdd8 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/ServerStats.java @@ -0,0 +1,33 @@ +package robaho.net.httpserver; + +import java.util.concurrent.atomic.AtomicLong; + +class ServerStats { + final AtomicLong connectionCount = new AtomicLong(); + final AtomicLong requestCount = new AtomicLong(); + final AtomicLong handleExceptionCount = new AtomicLong(); + final AtomicLong socketExceptionCount = new AtomicLong(); + final AtomicLong idleCloseCount = new AtomicLong(); + final AtomicLong replyErrorCount = new AtomicLong(); + final AtomicLong maxConnectionsExceededCount = new AtomicLong(); + + private volatile long lastStatsTime = System.currentTimeMillis(); + + public String stats() { + long now = System.currentTimeMillis(); + double secs = (now-lastStatsTime)/1000.0; + lastStatsTime = now; + + long _requests = requestCount.getAndSet(0); + + return + "Connections Since: "+connectionCount.getAndSet(0)+"\n" + + "Requests Since: "+_requests+"\n" + + "Requests/sec: "+(long)(_requests/secs)+"\n"+ + "Total Handler Exceptions: "+handleExceptionCount.get()+"\n"+ + "Total Socket Exceptions: "+socketExceptionCount.get()+"\n"+ + "Total Max Connections Exceeded: "+maxConnectionsExceededCount.get()+"\n"+ + "Total Idle Closes: "+idleCloseCount.get()+"\n"+ + "Total Reply Errors: "+replyErrorCount.get()+"\n"; + } +} \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java index 0a52bbd..63a4014 100644 --- a/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java @@ -8,7 +8,6 @@ import java.lang.System.Logger.Level; import java.net.InetSocketAddress; import java.util.ArrayList; -import java.util.EnumSet; import java.util.List; import java.util.concurrent.Executor; import java.util.concurrent.atomic.AtomicBoolean; @@ -70,11 +69,15 @@ public class HTTP2Connection { final AtomicInteger receiveWindow = new AtomicInteger(65535); final AtomicInteger requestsInProgress = new AtomicInteger(); + final HTTP2Stats stats; + + private final int connectionWindowSize; + private int maxConcurrentStreams = -1; private int highNumberStreams = 0; private final Lock lock = new ReentrantLock(); - private AtomicBoolean closed = new AtomicBoolean(false); + private final AtomicBoolean closed = new AtomicBoolean(false); /** * Constructor to instantiate HTTP2Connection object @@ -82,21 +85,32 @@ public class HTTP2Connection { * @param input HTTP2Client passes the ExBufferedInputStream * @param output */ - public HTTP2Connection(HttpConnection httpConnection, InputStream input, OutputStream output, StreamHandler handler) { + public HTTP2Connection(HttpConnection httpConnection, HTTP2Stats stats, InputStream input, OutputStream output, StreamHandler handler) { this.httpConnection = httpConnection; this.inputStream = input; this.outputStream = output; this.handler = handler; + this.stats = stats; this.logger = System.getLogger("robaho.net.httpserver.http2"); + connectionWindowSize = ServerConfig.http2ConnectionWindowSize(); + localSettings.set(new SettingParameter(SettingIdentifier.SETTINGS_MAX_FRAME_SIZE, ServerConfig.http2MaxFrameSize())); localSettings.set(new SettingParameter(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE, ServerConfig.http2InitialWindowSize())); + if (ServerConfig.http2MaxConcurrentStreams() != -1) { localSettings.set(new SettingParameter(SettingIdentifier.SETTINGS_MAX_CONCURRENT_STREAMS, ServerConfig.http2MaxConcurrentStreams())); } logger.log(Level.DEBUG, "opened http2 connection " + httpConnection + ", max concurrent streams " + ServerConfig.http2MaxConcurrentStreams()); } + public void debug() { + logger.log(Level.INFO,toString()+" receive window "+receiveWindow.get()+" send window "+sendWindow.get()+" in progress "+requestsInProgress.get()); + for(var stream : http2Streams.values()) { + stream.debug(); + } + } + void lock() { lock.lock(); } @@ -144,6 +158,7 @@ public void writeFrame(List partials) throws IOException { outputStream.write(frame); } outputStream.flush(); + stats.flushes.incrementAndGet(); } finally { unlock(); } @@ -241,7 +256,7 @@ private void processFrames() throws Exception { break; case DATA: DataFrame dataFrame = (DataFrame) frame; - if (receiveWindow.addAndGet(-dataFrame.body.length) <= 0) { + if (receiveWindow.addAndGet(-dataFrame.body.length) < connectionWindowSize/10) { sendWindowUpdate(); } if (inHeaders) { @@ -341,10 +356,15 @@ private void processFrames() throws Exception { http2Streams.put(streamId, targetStream); lastSeenStreamId = streamId; } else { - if (streamId < lastSeenStreamId) { - throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED, "stream " + streamId + " is closed"); + if (streamId <= lastSeenStreamId) { + if(frame.getHeader().getType()==FrameType.WINDOW_UPDATE) { + // must accept window update even if stream is closed + logger.log(Level.TRACE,() -> "received WINDOW_UPDATE on closed stream "+streamId); + continue; + } + throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED, "frame "+frame.getHeader().getType()+ ", stream " + streamId + " is closed"); } - throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "Stream ID not in order"); + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "frame "+frame.getHeader().getType()+", stream "+streamId+" not in order"); } targetStream.processFrame(frame); @@ -388,17 +408,19 @@ public void sendMySettings() throws IOException { localSettings.forEach(setting -> frame.getSettingParameters().add(setting)); HTTP2Connection.this.writeFrame(frame.encode()); } finally { - logger.log(Level.TRACE, () -> "Sent My Settings"); + logger.log(Level.TRACE, () -> "sent My Settings"); } } public void sendWindowUpdate() throws IOException { + int current = receiveWindow.get(); try { - receiveWindow.addAndGet(65535); - WindowUpdateFrame frame = new WindowUpdateFrame(0, 65535); + int increment = connectionWindowSize-current; + receiveWindow.addAndGet(increment); + WindowUpdateFrame frame = new WindowUpdateFrame(0, increment); HTTP2Connection.this.writeFrame(frame.encode()); } finally { - logger.log(Level.TRACE, () -> "Sent My Settings"); + logger.log(Level.DEBUG, () -> "sent connection window update, previous "+current+", now "+ receiveWindow.get()); } } @@ -431,6 +453,7 @@ public void sendResetStream(HTTP2ErrorCode errorCode, int streamId) throws IOExc public void sendPing() throws IOException { PingFrame frame = new PingFrame(); HTTP2Connection.this.writeFrame(frame.encode()); + stats.pingsSent.incrementAndGet(); logger.log(Level.TRACE, () -> "Sent Ping "); } diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Stats.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Stats.java new file mode 100644 index 0000000..00bc109 --- /dev/null +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Stats.java @@ -0,0 +1,39 @@ +package robaho.net.httpserver.http2; + +import java.util.concurrent.atomic.AtomicLong; + +public class HTTP2Stats { + public final AtomicLong activeStreams = new AtomicLong(); + public final AtomicLong bytesSent = new AtomicLong(); + public final AtomicLong framesSent = new AtomicLong(); + public final AtomicLong flushes = new AtomicLong(); + public final AtomicLong sslConnections = new AtomicLong(); + public final AtomicLong nonsslConnections = new AtomicLong(); + public final AtomicLong totalStreams = new AtomicLong(); + public final AtomicLong pauses = new AtomicLong(); + public final AtomicLong pingsSent = new AtomicLong(); + + private volatile long lastStatsTime = System.currentTimeMillis(); + + public String stats() { + long now = System.currentTimeMillis(); + double secs = (now-lastStatsTime)/1000.0; + lastStatsTime = now; + + long _bytes = bytesSent.getAndSet(0); + long _frames = framesSent.getAndSet(0); + + return + "Http2 SSL Connections Since: "+sslConnections.getAndSet(0)+"\n" + + "Http2 Non-SSL Connections Since: "+nonsslConnections.getAndSet(0)+"\n" + + "Http2 Streams Since: "+totalStreams.getAndSet(0)+"\n" + + "Http2 Active Streams: "+activeStreams.get()+"\n" + + "Http2 Frames Sent/sec: "+(long)(_frames/(secs))+"\n"+ + "Http2 Bytes Sent/sec: "+(long)(_bytes/(secs))+"\n"+ + "Http2 Avg Frame Size: "+(long)(_frames==0 ? 0 : _bytes/_frames)+"\n"+ + "Http2 Flushes/sec: "+(long)(flushes.getAndSet(0)/(secs))+"\n"+ + "Http2 Pauses/sec: "+(long)(pauses.getAndSet(0)/(secs))+"\n"+ + "Http2 Pings Sent Since: "+pingsSent.getAndSet(0)+"\n"; + + } +} diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java index 59020d7..9d0ee5d 100644 --- a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java @@ -6,18 +6,15 @@ import java.lang.System.Logger; import java.lang.System.Logger.Level; import java.net.InetSocketAddress; -import java.util.EnumSet; +import java.util.Arrays; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; -import java.util.concurrent.locks.Condition; -import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.LockSupport; -import java.util.concurrent.locks.ReentrantLock; import com.sun.net.httpserver.Headers; -import robaho.net.httpserver.NoSyncBufferedInputStream; import robaho.net.httpserver.NoSyncBufferedOutputStream; import robaho.net.httpserver.OptimizedHeaders; import robaho.net.httpserver.http2.hpack.HPackContext; @@ -37,11 +34,13 @@ public class HTTP2Stream { // needs to be accessible for connection to adjust based on SettingsFrame final AtomicLong sendWindow = new AtomicLong(65535); + private final AtomicLong receiveWindow = new AtomicLong(65535); + private final int initialWindowSize; private final HTTP2Connection connection; private final Logger logger; private final OutputStream outputStream; - private final Pipe pipe; + private final DataIn dataIn; private final HTTP2Connection.StreamHandler handler; private final Headers requestHeaders; private final Headers responseHeaders = new OptimizedHeaders(16); @@ -50,6 +49,7 @@ public class HTTP2Stream { private volatile Thread thread; private volatile boolean streamOpen = true; private volatile boolean halfClosed = false; + private volatile AtomicBoolean handlingRequest = new AtomicBoolean(false); private long dataInSize = 0; @@ -59,14 +59,21 @@ public HTTP2Stream(int streamId, HTTP2Connection connection, Headers requestHead this.logger = connection.logger; this.requestHeaders = requestHeaders; this.handler = handler; - this.pipe = new Pipe(); + this.dataIn = new DataIn(); this.outputStream = new NoSyncBufferedOutputStream(new Http2OutputStream(streamId)); var setting = connection.getRemoteSettings().get(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE); if(setting!=null) { - sendWindow.addAndGet((int)(setting.value-65535)); + sendWindow.set((int)(setting.value)); + } + setting = connection.getLocalSettings().get(SettingIdentifier.SETTINGS_INITIAL_WINDOW_SIZE); + if(setting!=null) { + receiveWindow.set((int)(setting.value)); + initialWindowSize = (int)setting.value; + } else { + initialWindowSize = 65535; } if(logger.isLoggable(Level.TRACE)) { - logger.log(Level.TRACE,() -> "new stream, window size "+sendWindow.get()+" on stream "+streamId); + logger.log(Level.TRACE,() -> "new stream, send window size "+sendWindow.get()+", receive window size "+receiveWindow.get()+" on stream "+streamId); } } @@ -84,7 +91,13 @@ public Headers getResponseHeaders() { @Override public String toString() { - return connection.toString()+", stream "+streamId; + return connection.httpConnection.toString()+" stream "+streamId; + } + + public void debug() { + logger.log(Level.INFO,connection.toString()+", stream "+streamId+" open "+streamOpen+" half closed "+halfClosed+", thread "+thread); + logger.log(Level.INFO,connection.toString()+", stream "+streamId+" data in size "+dataInSize+" expected "+expectedSize()); + logger.log(Level.INFO,""+Arrays.toString(thread.getStackTrace())); } public boolean isOpen() { @@ -94,6 +107,13 @@ public boolean isHalfClosed() { return halfClosed; } + private long expectedSize() { + if(requestHeaders.containsKey("Content-length")) { + return Long.parseLong(requestHeaders.getFirst("Content-length")); + } + return -1; + } + public void close() { streamOpen = false; halfClosed = true; @@ -101,9 +121,10 @@ public void close() { if(connection.http2Streams.put(streamId,null)==null) { return; } + logger.log(Level.TRACE,() -> "closing stream "+streamId); try { - pipe.close(); + dataIn.close(); outputStream.close(); if(thread!=null) thread.interrupt(); @@ -124,6 +145,7 @@ public void processFrame(BaseFrame frame) throws HTTP2Exception, IOException { if(halfClosed) { throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED); } + performRequest(frame.getHeader().getFlags().contains(FrameFlag.END_STREAM)); break; case DATA: @@ -135,17 +157,16 @@ public void processFrame(BaseFrame frame) throws HTTP2Exception, IOException { if(!streamOpen) { throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR); } - pipe.getOutputStream().write(dataFrame.body); + dataIn.enqueue(dataFrame.body); dataInSize += dataFrame.body.length; if (dataFrame.getHeader().getFlags().contains(FrameFlag.END_STREAM)) { - if(requestHeaders.containsKey("Content-length")) { - if(dataInSize!=Long.parseLong(requestHeaders.getFirst("Content-length"))) { - connection.sendResetStream(HTTP2ErrorCode.PROTOCOL_ERROR, streamId); - close(); - break; - } + long expected = expectedSize(); + if(expected!=-1 && dataInSize!=expected) { + connection.sendResetStream(HTTP2ErrorCode.PROTOCOL_ERROR, streamId); + close(); + break; } - pipe.closeOutput(); + dataIn.close(); halfClosed = true; } break; @@ -172,15 +193,19 @@ public void processFrame(BaseFrame frame) throws HTTP2Exception, IOException { } } - private void performRequest(boolean halfClosed) throws IOException { + private void performRequest(boolean halfClosed) throws IOException, HTTP2Exception { + if(!handlingRequest.compareAndSet(false, true)) { + throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"already received headers for stream "+streamId); + } connection.httpConnection.requestCount.incrementAndGet(); connection.requestsInProgress.incrementAndGet(); + connection.stats.activeStreams.incrementAndGet(); - InputStream in = halfClosed ? InputStream.nullInputStream() : pipe.getInputStream(); + InputStream in = halfClosed ? InputStream.nullInputStream() : dataIn; if(halfClosed) { this.halfClosed = true; - pipe.closeOutput(); + dataIn.close(); } handler.getExecutor().execute(() -> { @@ -188,18 +213,19 @@ private void performRequest(boolean halfClosed) throws IOException { try { handler.handleStream(this,in,outputStream); } catch (IOException ex) { + logger.log(Level.DEBUG,"io exception on stream "+streamId,ex); close(); } }); } public void writeResponseHeaders() throws IOException { - if(!headersSent.compareAndSet(false,true)) - return; - connection.lock(); - try { - HPackContext.writeHeaderFrame(responseHeaders,connection.outputStream,streamId); - } finally { - connection.unlock(); + if(headersSent.compareAndSet(false,true)) { + connection.lock(); + try { + HPackContext.writeHeaderFrame(responseHeaders,connection.outputStream,streamId); + } finally { + connection.unlock(); + } } } public InetSocketAddress getLocalAddress() { @@ -216,7 +242,6 @@ class Http2OutputStream extends OutputStream { private final int streamId; private final int max_frame_size; private boolean closed; - private long pauses = 0; public Http2OutputStream(int streamId) { this.streamId = streamId; @@ -236,18 +261,20 @@ public void write(byte[] b) throws IOException { @Override public void write(byte[] b, int off, int len) throws IOException { + connection.stats.bytesSent.addAndGet(len); // test outside of lock so other streams can progress while(sendWindow.get()<=0 && !connection.isClosed()) { - pauses++; + connection.stats.pauses.incrementAndGet(); LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); } writeResponseHeaders(); while(len>0) { int _len = Math.min(Math.min(len,max_frame_size),(int)Math.min(connection.sendWindow.get(),sendWindow.get())); if(_len<=0) { - pauses++; + connection.stats.pauses.incrementAndGet(); connection.lock(); try { + connection.stats.flushes.incrementAndGet(); connection.outputStream.flush(); } finally { connection.unlock(); @@ -269,6 +296,7 @@ public void write(byte[] b, int off, int len) throws IOException { try { FrameHeader.writeTo(connection.outputStream, _len, FrameType.DATA, FrameFlag.NONE, streamId); connection.outputStream.write(b,off,_len); + connection.stats.framesSent.incrementAndGet(); } finally { connection.unlock(); } @@ -287,72 +315,56 @@ public void flush() throws IOException { @Override public void close() throws IOException { if(closed) return; - if(pauses>0) - logger.log(Level.INFO,() -> "sending stream window exhausted "+pauses+" on stream "+streamId); try { if(connection.isClosed()) { - if(!headersSent.get()) { + if(headersSent.compareAndSet(false,true)) { logger.log(Level.WARNING,"stream connection is closed and headers not sent on stream "+streamId); } return; } - connection.requestsInProgress.decrementAndGet(); writeResponseHeaders(); connection.lock(); + boolean lastRequest = connection.requestsInProgress.decrementAndGet() == 0; try { FrameHeader.writeTo(connection.outputStream, 0, FrameType.DATA, END_STREAM, streamId); - if(connection.requestsInProgress.get()<=0) { + connection.stats.framesSent.incrementAndGet(); + if(lastRequest) { connection.outputStream.flush(); + connection.stats.flushes.incrementAndGet(); } } finally { connection.unlock(); } - // connection.enqueue(header); } finally { + connection.stats.activeStreams.decrementAndGet(); closed=true; HTTP2Stream.this.close(); } } } - // custom Pipe implementation since JDK version still uses synchronized methods which are not optimal for virtual threads - private static class Pipe { - private final InputStream inputStream; - private final CustomPipedOutputStream outputStream; - - public Pipe() { - var pipeIn = new CustomPipedInputStream(); - this.inputStream = new NoSyncBufferedInputStream(pipeIn); - this.outputStream = new CustomPipedOutputStream(pipeIn); - } + // the data InputStream passed to handlers + private class DataIn extends InputStream { + private final ConcurrentLinkedQueue queue = new ConcurrentLinkedQueue<>(); + private volatile Thread reader; + /** offset into the top of the queue array */ + private int offset = 0; + private volatile boolean closed; - public InputStream getInputStream() { - return inputStream; + public DataIn() { } - public OutputStream getOutputStream() { - return outputStream; + void enqueue(byte[] data) { + queue.add(data); + LockSupport.unpark(reader); } + @Override public void close() throws IOException { - inputStream.close(); - outputStream.close(); + closed=true; + LockSupport.unpark(reader); } - public void closeOutput() throws IOException { - outputStream.close(); - } - } - - private static class CustomPipedInputStream extends InputStream { - private final byte[] buffer = new byte[1024]; - private int readPos = 0; - private int writePos = 0; - private boolean closed = false; - private final Lock lock = new ReentrantLock(); - private final Condition notEmpty = lock.newCondition(); - private final Condition notFull = lock.newCondition(); - private final byte[] single = new byte[1]; @Override @@ -363,108 +375,47 @@ public int read() throws IOException { @Override public int read(byte[] b, int off, int len) throws IOException { - lock.lock(); + int read = 0; try { - while (readPos == writePos && !closed) { - try { - notEmpty.await(); - } catch (InterruptedException e) { - throw new IOException("Interrupted while waiting for data", e); + reader = Thread.currentThread(); + for(;len>0;) { + byte[] data; + while((data=queue.peek())==null) { + if(read>0) { + return read; + } + if(closed) return -1; + LockSupport.park(); + if(Thread.interrupted()) { + throw new IOException("interrupted"); + } + } + int available = data.length-offset; + int bytesToRead = Math.min(len, available); + System.arraycopy(data, offset, b, off, bytesToRead); + offset+=bytesToRead; + off+=bytesToRead; + len-=bytesToRead; + available-=bytesToRead; + read+=bytesToRead; + if(available==0) { // remove top buffer from queue + queue.poll(); + offset=0; } } - if (closed && readPos == writePos) { - return -1; - } - - int available; - if (readPos <= writePos) { - available = writePos - readPos; - } else { - available = buffer.length - readPos; - } - - int bytesToRead = Math.min(len, available); - System.arraycopy(buffer, readPos, b, off, bytesToRead); - readPos += bytesToRead; - if (readPos == buffer.length) { - readPos = 0; - } - notFull.signal(); - return bytesToRead; + return read; } finally { - lock.unlock(); - } - } - - @Override - public void close() throws IOException { - lock.lock(); - try { - closed = true; - notEmpty.signalAll(); - notFull.signalAll(); - } finally { - lock.unlock(); - } - } - } - - private static class CustomPipedOutputStream extends OutputStream { - private final CustomPipedInputStream inputStream; - private boolean closed = false; - - public CustomPipedOutputStream(CustomPipedInputStream inputStream) { - this.inputStream = inputStream; - } - - @Override - public void write(int b) throws IOException { - write(new byte[]{(byte) b}); - } - - @Override - public void write(byte[] b, int off, int len) throws IOException { - inputStream.lock.lock(); - try { - while (len > 0) { - while ((inputStream.writePos == inputStream.readPos - 1 || - (inputStream.writePos == inputStream.buffer.length - 1 && inputStream.readPos == 0)) - && !closed) { - try { - inputStream.notFull.await(); - } catch (InterruptedException e) { - throw new IOException("Interrupted while waiting for buffer space", e); - } - } - if (closed) { - throw new IOException("Stream closed"); - } - int space = inputStream.readPos <= inputStream.writePos ? - inputStream.buffer.length - inputStream.writePos : - inputStream.readPos - inputStream.writePos - 1; - int bytesToWrite = Math.min(len, space); - System.arraycopy(b, off, inputStream.buffer, inputStream.writePos, bytesToWrite); - inputStream.writePos += bytesToWrite; - if (inputStream.writePos == inputStream.buffer.length) { - inputStream.writePos = 0; + if(receiveWindow.addAndGet(-read) "sent stream window update, receive window "+receiveWindow.get()+" on stream "+streamId); + } finally { + connection.unlock(); } - off += bytesToWrite; - len -= bytesToWrite; - inputStream.notEmpty.signal(); } - } finally { - inputStream.lock.unlock(); - } - } - - @Override - public void close() throws IOException { - inputStream.lock.lock(); - try { - closed = true; - inputStream.close(); - } finally { - inputStream.lock.unlock(); } } } From 4fc6fbb37bdc8f65fd3aa78eff6d11fb5a47056e Mon Sep 17 00:00:00 2001 From: robert engels Date: Wed, 8 Jan 2025 23:23:05 -0600 Subject: [PATCH 39/78] version 1.0.18 --- README.md | 2 +- build.gradle | 50 ++++++++++++++++++++++++++++---------------------- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 0941118..bde5bea 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ The counts and rates for non "Total" statistics are reset with each pull of the io.github.robaho httpserver - 1.0.17 + 1.0.18 ``` ## enable Http2 diff --git a/build.gradle b/build.gradle index a1ba98b..d35f2c2 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,8 @@ 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.http2OverNonSSL","true") // systemProperty("javax.net.debug","ssl:handshake:verbose:keymanager:trustmanager") } @@ -36,6 +38,11 @@ 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.http2OverNonSSL","true") + systemProperty("robaho.net.httpserver.http2InitialWindowSize","1024000") + systemProperty("robaho.net.httpserver.http2ConnectionWindowSize","1024000000") + systemProperty("robaho.net.httpserver.EnableStats","true") } dependencies { @@ -84,6 +91,7 @@ sourceSets { } task runSingleUnitTest(type: Test) { + outputs.upToDateWhen { false } dependsOn testClasses filter { includeTestsMatching 'InputNotRead' @@ -91,11 +99,12 @@ task runSingleUnitTest(type: Test) { useTestNG() } -/** used for developmet to run a single test */ +/** used for development to run a single test */ task runSingleMainTest(type: Test) { + outputs.upToDateWhen { false } dependsOn testMainsClasses doLast { - def testname = "SecureProxyHandlerTest" + def testname = "B6361557" println jvmArgs println systemProperties def props = systemProperties @@ -127,30 +136,27 @@ task testMainsTest(type: Test) { } } -task runSimpleFileServer(type: Test) { - javaLauncher = javaToolchains.launcherFor { - languageVersion = JavaLanguageVersion.of(23) +task runSimpleFileServer(type: JavaExec) { + doFirst { + mkdir 'fileserver' } dependsOn testClasses - doLast { - def props = systemProperties - mkdir 'fileserver' - javaexec { - classpath sourceSets.test.runtimeClasspath - main "SimpleFileServer" - systemProperties = props - args = ['fileserver','8080','fileserver/logfile.txt'] - // debugOptions { - // enabled = true - // suspend = false - // } - } + classpath sourceSets.test.runtimeClasspath + main "SimpleFileServer" + args = ['fileserver','8080','fileserver/logfile.txt'] + javaLauncher = javaToolchains.launcherFor { + languageVersion = JavaLanguageVersion.of(23) } + // debugOptions { + // enabled = true + // suspend = true + // } } -task run(type: JavaExec) { - classpath sourceSets.testMains.runtimeClasspath - dependsOn testMainsClasses +task testJar(type: Jar) { + archiveClassifier.set("test") + from sourceSets.test.output, sourceSets.testMains.output + duplicatesStrategy = DuplicatesStrategy.EXCLUDE } task runAllTests(type: Test) { @@ -167,7 +173,7 @@ publishing { maven(MavenPublication) { groupId = 'io.github.robaho' artifactId = 'httpserver' - version = "1.0.16" + version = "1.0.18" from components.java From 7722acb2ff6513d7f8b401ea88747a8ed00ce031 Mon Sep 17 00:00:00 2001 From: Josiah Noel <32279667+SentryMan@users.noreply.github.com> Date: Thu, 9 Jan 2025 00:39:24 -0500 Subject: [PATCH 40/78] [README] Add basic setup example --- README.md | 64 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index bde5bea..9f232a8 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,74 @@ # httpserver -A zero-dependency implementation of the JDK com.sun.net.httpserver.HttpServer specification 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. -It adds websocket support using modified source from nanohttpd. - -It has basic server-side proxy support using [ProxyHandler](https://github.com/robaho/httpserver/blob/main/src/main/java/robaho/net/httpserver/extras/ProxyHandler.java). - -ProxyHandler also supports tunneling proxies using CONNECT for https. - -It supports Http2 [RFC 9113](https://www.rfc-editor.org/rfc/rfc9113.html) +- 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.) +- HTTP/2 [RFC 9113](https://www.rfc-editor.org/rfc/rfc9113.html) support +- Performance enhancements such as proper HTTP pipelining, optimized String parsing, etc. All async functionality has been removed. All synchronized blocks were removed in favor of other Java concurrency concepts. The end result is an implementation that easily integrates with Virtual Threads available in JDK 21 - simply set a virtual thread based ExecutorService. -Improved performance by more than **10x** over the JDK implementation, using http pipelining, optimized String parsing, etc. +Improves performance by more than **10x** over the JDK implementation. Designed for embedding with only a 90kb jar and zero dependencies. ## background -The JDK httpserver implementation has no support for connection upgrades, so it is not possible to add websocket support. +The built-in JDK httpserver implementation has no support for connection upgrades, so it is not possible to add websocket support. Additionally, the code still has a lot of async - e.g. using SSLEngine to provide SSL support - which makes it more difficult to understand and enhance. -The streams based processing and thread per connection design simplifies the code substantially. +The stream-based processing and thread-per-connection design simplifies the code substantially. ## testing -Nearly all of the tests were included from the JDK so this version should be highly compliant and reliable. +Nearly all tests were included from the JDK, so this version should be highly compliant and reliable. ## using -Set the default HttpServer provider when starting the jvm: +The JDK will automatically use `robaho.net.httpserver.DefaultHttpServerProvider` in the place of the default implementation when the jar is placed on the class/module path. --Dcom.sun.net.httpserver.HttpServerProvider=robaho.net.httpserver.DefaultHttpServerProvider - -or instantiate the server directly using [this](https://github.com/robaho/httpserver/blob/main/src/main/java/robaho/net/httpserver/DefaultHttpServerProvider.java#L33). +```java +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +public class Test { + + public static void main(String[] args) throws Exception { + HttpServer server = HttpServer.create(new InetSocketAddress(8000), 0); + server.createContext("/", new MyHandler()); + server.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); // sets virtual thread executor + server.start(); + } + + static class MyHandler implements HttpHandler { + @Override + public void handle(HttpExchange exchange) throws IOException { + String response = "This is the response"; + byte[] bytes = response.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(200, contentLength); + os.write(bytes); + os.flush(); + } + } + } +} +``` -or the service loader will automatically find it when the jar is placed on the class path when using the standard HttpServer service provider. +Alternatively, you can instantiate the server directly using [this](https://github.com/robaho/httpserver/blob/main/src/main/java/robaho/net/httpserver/DefaultHttpServerProvider.java#L33). ## performance From 2c2e4b2f1e8ea11c9ab1eebe7d35f7c1fa124ed4 Mon Sep 17 00:00:00 2001 From: Josiah Noel <32279667+SentryMan@users.noreply.github.com> Date: Thu, 9 Jan 2025 09:36:27 -0500 Subject: [PATCH 41/78] Update README.md --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 9f232a8..aa2c1b5 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,6 @@ public class Test { try (OutputStream os = exchange.getResponseBody()) { exchange.sendResponseHeaders(200, contentLength); os.write(bytes); - os.flush(); } } } From c07fa733b8d49e5dcdc8950e64fe5ca51533cc4c Mon Sep 17 00:00:00 2001 From: Josiah Noel <32279667+SentryMan@users.noreply.github.com> Date: Fri, 10 Jan 2025 13:14:19 -0500 Subject: [PATCH 42/78] Update README.md --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index aa2c1b5..efeaf06 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,11 @@ Nearly all tests were included from the JDK, so this version should be highly co ## using -The JDK will automatically use `robaho.net.httpserver.DefaultHttpServerProvider` in the place of the default implementation when the jar is placed on the class/module path. +The JDK will automatically use `robaho.net.httpserver.DefaultHttpServerProvider` in the place of the default implementation when the jar is placed on the class/module path. If there are multiple `HttpServer` providers on the classpath, we can use the following property when starting the JVM to specify the correct one -Dcom.sun.net.httpserver.HttpServerProvider=robaho.net.httpserver.DefaultHttpServerProvider +Alternatively, you can instantiate the server directly using [this](https://github.com/robaho/httpserver/blob/main/src/main/java/robaho/net/httpserver/DefaultHttpServerProvider.java#L33). + +### Example Usage ```java import java.io.IOException; import java.io.OutputStream; @@ -67,8 +70,6 @@ public class Test { } ``` -Alternatively, you can instantiate the server directly using [this](https://github.com/robaho/httpserver/blob/main/src/main/java/robaho/net/httpserver/DefaultHttpServerProvider.java#L33). - ## performance This verson performs more than **10x** better than the JDK version when tested using the [Tech Empower Benchmarks](https://github.com/TechEmpower/FrameworkBenchmarks/tree/master/frameworks/Java/httpserver) on an identical hardware/work setup with the same JDK 23 version.1 From dca3a465fc6d2d51bf32b95cec9f65853c8c9da2 Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 10 Jan 2025 12:50:30 -0600 Subject: [PATCH 43/78] optimize headers only response on http2. must read all inbound --- .../net/httpserver/Http2ExchangeImpl.java | 2 +- .../robaho/net/httpserver/ServerImpl.java | 2 +- .../net/httpserver/http2/HTTP2Connection.java | 2 +- .../net/httpserver/http2/HTTP2Stream.java | 35 +++++++++++++++---- .../httpserver/http2/hpack/HPackContext.java | 6 ++-- 5 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java b/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java index cbb3d7f..1660d4a 100644 --- a/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java +++ b/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java @@ -88,7 +88,7 @@ public void sendResponseHeaders(int rCode, long responseLength) throws IOExcepti } response.set(":status",Long.toString(rCode)); responseCode = rCode; - stream.writeResponseHeaders(); + stream.writeResponseHeaders(responseLength==-1); } @Override diff --git a/src/main/java/robaho/net/httpserver/ServerImpl.java b/src/main/java/robaho/net/httpserver/ServerImpl.java index 7c7dc86..b91d038 100644 --- a/src/main/java/robaho/net/httpserver/ServerImpl.java +++ b/src/main/java/robaho/net/httpserver/ServerImpl.java @@ -574,7 +574,7 @@ public void handleStream(HTTP2Stream stream,InputStream in, OutputStream out) th if (ctx == null) { logger.log(Level.DEBUG, "No context found for request "+uriPath+", rejecting as not found"); response.set(":status","404"); - stream.writeResponseHeaders(); + stream.writeResponseHeaders(true); out.close(); return; } diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java index 63a4014..d49310c 100644 --- a/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Connection.java @@ -362,7 +362,7 @@ private void processFrames() throws Exception { logger.log(Level.TRACE,() -> "received WINDOW_UPDATE on closed stream "+streamId); continue; } - throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED, "frame "+frame.getHeader().getType()+ ", stream " + streamId + " is closed"); + throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED, "frame "+frame.getHeader().getType()+ ", length "+ frame.getHeader().getLength()+", stream " + streamId + " is closed"); } throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR, "frame "+frame.getHeader().getType()+", stream "+streamId+" not in order"); } diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java index 9d0ee5d..39980e6 100644 --- a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java @@ -49,6 +49,7 @@ public class HTTP2Stream { private volatile Thread thread; private volatile boolean streamOpen = true; private volatile boolean halfClosed = false; + private volatile boolean streamOutClosed = false; private volatile AtomicBoolean handlingRequest = new AtomicBoolean(false); private long dataInSize = 0; @@ -218,16 +219,26 @@ private void performRequest(boolean halfClosed) throws IOException, HTTP2Excepti } }); } - public void writeResponseHeaders() throws IOException { - if(headersSent.compareAndSet(false,true)) { + /** + * @param closeStream if true the output stream is closed, and any attempts + * to write data to the stream will fail. This is an optimization that + * allows the CLOSE_STREAM bit to be set in the Headers frame, reducing the + * packet count. + */ + public void writeResponseHeaders(boolean closeStream) throws IOException { + if (headersSent.compareAndSet(false, true)) { connection.lock(); try { - HPackContext.writeHeaderFrame(responseHeaders,connection.outputStream,streamId); + HPackContext.writeHeaderFrame(responseHeaders, connection.outputStream, streamId, closeStream); + if (closeStream) { + streamOutClosed = true; + } } finally { connection.unlock(); } } } + public InetSocketAddress getLocalAddress() { return connection.getLocalAddress(); } @@ -267,7 +278,10 @@ public void write(byte[] b, int off, int len) throws IOException { connection.stats.pauses.incrementAndGet(); LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); } - writeResponseHeaders(); + writeResponseHeaders(false); + if(streamOutClosed) { + throw new IOException("output stream was closed during headers send"); + } while(len>0) { int _len = Math.min(Math.min(len,max_frame_size),(int)Math.min(connection.sendWindow.get(),sendWindow.get())); if(_len<=0) { @@ -322,12 +336,14 @@ public void close() throws IOException { } return; } - writeResponseHeaders(); + writeResponseHeaders(false); connection.lock(); boolean lastRequest = connection.requestsInProgress.decrementAndGet() == 0; try { - FrameHeader.writeTo(connection.outputStream, 0, FrameType.DATA, END_STREAM, streamId); - connection.stats.framesSent.incrementAndGet(); + if(!streamOutClosed) { + FrameHeader.writeTo(connection.outputStream, 0, FrameType.DATA, END_STREAM, streamId); + connection.stats.framesSent.incrementAndGet(); + } if(lastRequest) { connection.outputStream.flush(); connection.stats.flushes.incrementAndGet(); @@ -335,6 +351,10 @@ public void close() throws IOException { } finally { connection.unlock(); } + // same as http1, read all incoming frames when closing the output stream. + // TODO review this, as the http2 stream is bidirectional and the spec may allow the server to continue to process inbound frames + // after closing the outbound stream - similar to a http2 client + dataIn.readAllBytes(); } finally { connection.stats.activeStreams.decrementAndGet(); closed=true; @@ -355,6 +375,7 @@ public DataIn() { } void enqueue(byte[] data) { + if(closed) return; queue.add(data); LockSupport.unpark(reader); } diff --git a/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java b/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java index 988c034..902edca 100644 --- a/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java +++ b/src/main/java/robaho/net/httpserver/http2/hpack/HPackContext.java @@ -18,6 +18,7 @@ import robaho.net.httpserver.http2.frame.FrameFlag; import robaho.net.httpserver.http2.frame.FrameHeader; import robaho.net.httpserver.http2.Utils; +import robaho.net.httpserver.http2.frame.FrameFlag; import robaho.net.httpserver.http2.frame.FrameFlag.FlagSet; import robaho.net.httpserver.http2.frame.FrameType; @@ -210,7 +211,7 @@ private int decodeLiteralFieldNeverIndexed(byte[] buffer, int index, HTTP2Header } /** this method is optimized for a server implementation and is not suitable for generic http2 hpack header encoding */ - public static void writeHeaderFrame(Headers headers, OutputStream outputStream, int streamId) throws IOException { + public static void writeHeaderFrame(Headers headers, OutputStream outputStream, int streamId,boolean closeStream) throws IOException { ByteArrayOutputStream fields = new ByteArrayOutputStream(256); // average http headers length is 800 bytes // ':status' is required and the only allowed outbound pseudo headers @@ -238,7 +239,7 @@ public static void writeHeaderFrame(Headers headers, OutputStream outputStream, } }); - FrameHeader.writeTo(outputStream, fields.size(),FrameType.HEADERS, END_OF_HEADERS, streamId); + FrameHeader.writeTo(outputStream, fields.size(),FrameType.HEADERS,closeStream ? END_OF_HEADERS_AND_STREAM : END_OF_HEADERS, streamId); fields.writeTo(outputStream); } @@ -275,6 +276,7 @@ public static void writeGenericHeaderFrame(Headers headers, OutputStream outputS } private static final FlagSet END_OF_HEADERS = FlagSet.of(FrameFlag.END_HEADERS); + private static final FlagSet END_OF_HEADERS_AND_STREAM = FlagSet.of(FrameFlag.END_HEADERS,FrameFlag.END_STREAM); public static List encodeHeadersFrame(Headers headers,int streamId) { byte[] buffer = encodeHeaders(headers); From a18f421d0e2c0372dfca46bf20c4829763f47a34 Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 10 Jan 2025 18:09:18 -0600 Subject: [PATCH 44/78] install_local already provided by the gradle plugin --- install_local.sh | 1 - 1 file changed, 1 deletion(-) delete mode 100755 install_local.sh diff --git a/install_local.sh b/install_local.sh deleted file mode 100755 index abce590..0000000 --- a/install_local.sh +++ /dev/null @@ -1 +0,0 @@ -mvn install:install-file -Dfile=build/libs/httpserver.jar -DgroupId=io.github.robaho -DartifactId=httpserver -Dversion=$1 -Dpackaging=jar -DgeneratePom=true \ No newline at end of file From 4f234563489082a080bbce7e555a6aca2a722224 Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 10 Jan 2025 22:54:34 -0600 Subject: [PATCH 45/78] fix licenses. add version information to jars and manifests --- build.gradle | 49 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index d35f2c2..1f39938 100644 --- a/build.gradle +++ b/build.gradle @@ -43,6 +43,7 @@ tasks.withType(JavaExec) { systemProperty("robaho.net.httpserver.http2InitialWindowSize","1024000") systemProperty("robaho.net.httpserver.http2ConnectionWindowSize","1024000000") systemProperty("robaho.net.httpserver.EnableStats","true") + systemProperty("robaho.net.httpserver.EnableDebug","true") } dependencies { @@ -90,6 +91,43 @@ 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() +} + +version = getGitVersion() + +task showGitVersion { + doLast { + println "project version is "+version + } +} + +build { + doFirst { + getGitVersion + } +} + +jar { + manifest { + attributes( + "Implementation-Title": project.name, + "Implementation-Version": version) + } +} + task runSingleUnitTest(type: Test) { outputs.upToDateWhen { false } dependsOn testClasses @@ -173,13 +211,12 @@ publishing { maven(MavenPublication) { groupId = 'io.github.robaho' artifactId = 'httpserver' - version = "1.0.18" from components.java pom { name = 'HttpServer' - description = 'A zero dependency implements of the JDK httpserver designed for Virtual Threads. Includes websocket support.' + description = 'A zero dependency implementation of the JDK httpserver designed for Virtual Threads. Includes websocket and Http2 support.' signing { sign publishing.publications.maven @@ -194,13 +231,17 @@ publishing { licenses { license { - name = 'gnu v2.0' - url = 'https://www.gnu.org/licenses/old-licenses/gpl-2.0.html' + name = 'gnu v2.0 with classpath exception' + url = 'https://www.gnu.org/software/classpath/license.html' } license { name = 'nanohttpd' url = 'https://github.com/NanoHttpd/nanohttpd/blob/efb2ebf85a2b06f7c508aba9eaad5377e3a01e81/LICENSE.md' } + license { + name = 'MIT License' + url = 'https://opensource.org/licenses/MIT' + } } developers { From fc9c6715adfc8c32ceed79160f5c5ab2933bb99f Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 10 Jan 2025 22:55:19 -0600 Subject: [PATCH 46/78] http2: input stream should be closed if the output stream is closed --- .../net/httpserver/http2/HTTP2Stream.java | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java index 39980e6..e9af7c6 100644 --- a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java @@ -48,8 +48,10 @@ public class HTTP2Stream { private volatile Thread thread; private volatile boolean streamOpen = true; + // halfClosed is set when a END_STREAM is received. The streams are bidirectional. private volatile boolean halfClosed = false; - private volatile boolean streamOutClosed = false; + // streamOutputClosed is when the handler, either via close(), or sendResponseHeaders(code,-1) closes the output + private volatile boolean streamOutputClosed = false; private volatile AtomicBoolean handlingRequest = new AtomicBoolean(false); private long dataInSize = 0; @@ -96,7 +98,7 @@ public String toString() { } public void debug() { - logger.log(Level.INFO,connection.toString()+", stream "+streamId+" open "+streamOpen+" half closed "+halfClosed+", thread "+thread); + logger.log(Level.INFO,connection.toString()+", stream "+streamId+" open "+streamOpen+" half closed "+halfClosed+", streamOutputClosed "+streamOutputClosed+", thread "+thread); logger.log(Level.INFO,connection.toString()+", stream "+streamId+" data in size "+dataInSize+" expected "+expectedSize()); logger.log(Level.INFO,""+Arrays.toString(thread.getStackTrace())); } @@ -117,11 +119,11 @@ private long expectedSize() { public void close() { streamOpen = false; - halfClosed = true; if(connection.http2Streams.put(streamId,null)==null) { return; } + logger.log(Level.TRACE,() -> "closing stream "+streamId); try { @@ -146,8 +148,8 @@ public void processFrame(BaseFrame frame) throws HTTP2Exception, IOException { if(halfClosed) { throw new HTTP2Exception(HTTP2ErrorCode.STREAM_CLOSED); } - - performRequest(frame.getHeader().getFlags().contains(FrameFlag.END_STREAM)); + halfClosed = frame.getHeader().getFlags().contains(FrameFlag.END_STREAM); + performRequest(); break; case DATA: DataFrame dataFrame = (DataFrame) frame; @@ -167,8 +169,8 @@ public void processFrame(BaseFrame frame) throws HTTP2Exception, IOException { close(); break; } - dataIn.close(); halfClosed = true; + dataIn.wakeupReader(); } break; case PRIORITY: @@ -179,6 +181,7 @@ public void processFrame(BaseFrame frame) throws HTTP2Exception, IOException { case RST_STREAM: ResetStreamFrame resetFrame = (ResetStreamFrame) frame; logger.log(Level.DEBUG,"received reset stream "+resetFrame.errorCode+", on stream "+streamId); + halfClosed = true; close(); break; case WINDOW_UPDATE: @@ -194,7 +197,7 @@ public void processFrame(BaseFrame frame) throws HTTP2Exception, IOException { } } - private void performRequest(boolean halfClosed) throws IOException, HTTP2Exception { + private void performRequest() throws IOException, HTTP2Exception { if(!handlingRequest.compareAndSet(false, true)) { throw new HTTP2Exception(HTTP2ErrorCode.PROTOCOL_ERROR,"already received headers for stream "+streamId); } @@ -203,11 +206,6 @@ private void performRequest(boolean halfClosed) throws IOException, HTTP2Excepti connection.stats.activeStreams.incrementAndGet(); InputStream in = halfClosed ? InputStream.nullInputStream() : dataIn; - - if(halfClosed) { - this.halfClosed = true; - dataIn.close(); - } handler.getExecutor().execute(() -> { thread = Thread.currentThread(); @@ -231,7 +229,7 @@ public void writeResponseHeaders(boolean closeStream) throws IOException { try { HPackContext.writeHeaderFrame(responseHeaders, connection.outputStream, streamId, closeStream); if (closeStream) { - streamOutClosed = true; + streamOutputClosed = true; } } finally { connection.unlock(); @@ -279,7 +277,7 @@ public void write(byte[] b, int off, int len) throws IOException { LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); } writeResponseHeaders(false); - if(streamOutClosed) { + if(streamOutputClosed) { throw new IOException("output stream was closed during headers send"); } while(len>0) { @@ -340,7 +338,7 @@ public void close() throws IOException { connection.lock(); boolean lastRequest = connection.requestsInProgress.decrementAndGet() == 0; try { - if(!streamOutClosed) { + if(!streamOutputClosed) { FrameHeader.writeTo(connection.outputStream, 0, FrameType.DATA, END_STREAM, streamId); connection.stats.framesSent.incrementAndGet(); } @@ -351,10 +349,7 @@ public void close() throws IOException { } finally { connection.unlock(); } - // same as http1, read all incoming frames when closing the output stream. - // TODO review this, as the http2 stream is bidirectional and the spec may allow the server to continue to process inbound frames - // after closing the outbound stream - similar to a http2 client - dataIn.readAllBytes(); + dataIn.close(); } finally { connection.stats.activeStreams.decrementAndGet(); closed=true; @@ -369,21 +364,26 @@ private class DataIn extends InputStream { private volatile Thread reader; /** offset into the top of the queue array */ private int offset = 0; - private volatile boolean closed; public DataIn() { } void enqueue(byte[] data) { - if(closed) return; queue.add(data); LockSupport.unpark(reader); } + + void wakeupReader() { + LockSupport.unpark(reader); + } @Override public void close() throws IOException { - closed=true; - LockSupport.unpark(reader); + if(Thread.currentThread()==reader || reader == null) { + readAllBytes(); + } else { + LockSupport.unpark(reader); + } } private final byte[] single = new byte[1]; @@ -405,7 +405,7 @@ public int read(byte[] b, int off, int len) throws IOException { if(read>0) { return read; } - if(closed) return -1; + if(halfClosed) return -1; LockSupport.park(); if(Thread.interrupted()) { throw new IOException("interrupted"); From 72775986b38120b30dc4bc0438d21136ff8ec192 Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 10 Jan 2025 22:59:24 -0600 Subject: [PATCH 47/78] update readme --- README.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index efeaf06..4eb874a 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,21 @@ The built-in JDK httpserver implementation has no support for connection upgrade Additionally, the code still has a lot of async - e.g. using SSLEngine to provide SSL support - which makes it more difficult to understand and enhance. -The stream-based processing and thread-per-connection design simplifies the code substantially. +The thread-per-connection synchronous design simplifies the code substantially. -## testing +## testing/compliance -Nearly all tests were included from the JDK, so this version should be highly compliant and reliable. +Nearly all tests from the JDK are included, so this version should be highly compliant and reliable. + +Additional proxy and websockets tests are included. + +The http2 implementation passes all specification tests in [h2spec](https://github.com/summerwind/h2spec) ## using -The JDK will automatically use `robaho.net.httpserver.DefaultHttpServerProvider` in the place of the default implementation when the jar is placed on the class/module path. If there are multiple `HttpServer` providers on the classpath, we can use the following property when starting the JVM to specify the correct one -Dcom.sun.net.httpserver.HttpServerProvider=robaho.net.httpserver.DefaultHttpServerProvider +The JDK will automatically use `robaho.net.httpserver.DefaultHttpServerProvider` instead of the JDK implementation when the jar is placed on the class/module path. If there are multiple `HttpServer` providers on the classpath, the `com.sun.net.httpserver.HttpServerProvider` system property can be used to specify the correct one: + +Eg. -Dcom.sun.net.httpserver.HttpServerProvider=robaho.net.httpserver.DefaultHttpServerProvider Alternatively, you can instantiate the server directly using [this](https://github.com/robaho/httpserver/blob/main/src/main/java/robaho/net/httpserver/DefaultHttpServerProvider.java#L33). @@ -76,7 +82,7 @@ This verson performs more than **10x** better than the JDK version when tested u The frameworks were also tested using [go-wrk](https://github.com/tsliwowicz/go-wrk)2 -1_The robaho version has been submitted to the Tech Empower benchmarks project for 3-party confirmation._
+1_The [robaho version](https://github.com/TechEmpower/FrameworkBenchmarks/tree/master/frameworks/Java/httpserver-robaho) has been submitted to the Tech Empower benchmarks project for 3-party confirmation._
2_`go-wrk` does not use http pipelining so, the large number of connections is the limiting factor._ Performance tests against the latest Jetty version were run. The `robaho httpserver` outperformed the Jetty http2 by 3x, and http1 by 5x. @@ -278,7 +284,7 @@ The counts and rates for non "Total" statistics are reset with each pull of the io.github.robaho httpserver - 1.0.18 + 1.0.19 ``` ## enable Http2 @@ -291,8 +297,6 @@ Use `-Drobaho.net.httpserver.http2OverNonSSL=true` to enable Http2 on Non-SSL co See the additional Http2 options in `ServerConfig.java` -The http2 implementation passes all specification tests in [h2spec](https://github.com/summerwind/h2spec) - ## performance notes Http2 performance has not been fully optimized. The http2 version is about 20-30% slower than http1. I expect this to be the case with most http2 implementations due to the complexity. @@ -300,8 +304,8 @@ http2 outperforms http1 when sending multiple simultaneous requests from the cli TODO: sending hpack headers does not use huffman encoding or dynamic table management. see the following paper https://www.mew.org/~kazu/doc/paper/hpack-2017.pdf for optimizing the implementation further. -The most expensive operations involve converting strings to URI instances. Unfortunately, since using URI is part of the server specification little can be done in this regard. -It could be instantiated lazily, but almost all handlers need access to elements. +The most expensive operations involve converting strings to URI instances. Unfortunately, since using URI is part of the [HttpExchange API](https://docs.oracle.com/en/java/javase/11/docs/api/jdk.httpserver/com/sun/net/httpserver/HttpExchange.html#getRequestURI()) little can be done in this regard. +It could be instantiated lazily, but almost all handlers need access to the URI components (e.g. path, query, etc.) The standard JDK Headers implementation normalizes all headers by ensuring the first character is a capital letter, and the rest lowercase. A better solution would be to use all lowercase to match http2, so less conversions would be required. The scheme is also more complex than needs to be. So, Handler code should be written using the same scheme: From 72805165bfa062017f174b112943e0c1b8b1de8a Mon Sep 17 00:00:00 2001 From: robert engels Date: Sat, 11 Jan 2025 09:01:07 -0600 Subject: [PATCH 48/78] update readme --- README.md | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4eb874a..6160bc6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ The end result is an implementation that easily integrates with Virtual Threads Improves performance by more than **10x** over the JDK implementation. -Designed for embedding with only a 90kb jar and zero dependencies. +Designed for embedding with only a 200kb jar and zero dependencies. ## background @@ -74,8 +74,18 @@ public class Test { } } } + +``` +There is a [simple file server](https://github.com/robaho/httpserver/blob/72775986b38120b30dc4bc0438d21136ff8ec192/src/test/extras/SimpleFileServer.java#L48) that can be used to for basic testing. It has download, echo, and "hello" capabilities. Use + +``` +gradle runSimpleFileServer ``` +## 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) + ## performance This verson performs more than **10x** better than the JDK version when tested using the [Tech Empower Benchmarks](https://github.com/TechEmpower/FrameworkBenchmarks/tree/master/frameworks/Java/httpserver) on an identical hardware/work setup with the same JDK 23 version.1 @@ -307,16 +317,17 @@ TODO: sending hpack headers does not use huffman encoding or dynamic table manag The most expensive operations involve converting strings to URI instances. Unfortunately, since using URI is part of the [HttpExchange API](https://docs.oracle.com/en/java/javase/11/docs/api/jdk.httpserver/com/sun/net/httpserver/HttpExchange.html#getRequestURI()) little can be done in this regard. It could be instantiated lazily, but almost all handlers need access to the URI components (e.g. path, query, etc.) -The standard JDK Headers implementation normalizes all headers by ensuring the first character is a capital letter, and the rest lowercase. A better solution would be to use all lowercase to match http2, so less conversions would be required. The scheme is also more complex than needs to be. So, Handler code should be written using the same scheme: +The standard JDK Headers implementation normalizes all headers to be first character capitalized and the rest lowercase. To ensure optimum performance, client code should use the same format to avoid the normalization cost, i.e. ```java +Use + var value = request.getFirst("Content-length"); -vs +instead of -var value = request.getFirst("content-length"); +var value = request.getFirst("content-length"); +var value = request.getFirst("CONTENT-LENGTH"); ``` -for optimal performance. - From b841d5bbd72f183e313471339547667c2dcf7abf Mon Sep 17 00:00:00 2001 From: robert engels Date: Sat, 11 Jan 2025 09:01:28 -0600 Subject: [PATCH 49/78] fix usage text for SimpleFileServer --- src/test/extras/SimpleFileServer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/extras/SimpleFileServer.java b/src/test/extras/SimpleFileServer.java index 5ae3d8a..7d0684b 100644 --- a/src/test/extras/SimpleFileServer.java +++ b/src/test/extras/SimpleFileServer.java @@ -49,7 +49,7 @@ public class SimpleFileServer { public static void main(String[] args) throws Exception { if (args.length != 3) { - System.out.println("usage: java FileServerHandler rootDir port logfilename"); + System.out.println("usage: java SimpleFileServer rootDir port logfilename"); System.exit(1); } Logger logger = Logger.getLogger("com.sun.net.httpserver"); From e8f6e541a90cb3bdae389ce78bb5b330b018380c Mon Sep 17 00:00:00 2001 From: robert engels Date: Sat, 11 Jan 2025 09:57:25 -0600 Subject: [PATCH 50/78] support Principal auth on http2 streams --- .../robaho/net/httpserver/AuthFilter.java | 8 ++++- .../robaho/net/httpserver/ExchangeImpl.java | 9 ----- .../net/httpserver/Http2ExchangeImpl.java | 18 ++++++++-- .../robaho/net/httpserver/HttpConnection.java | 2 +- .../net/httpserver/HttpExchangeImpl.java | 9 ++--- .../net/httpserver/HttpsExchangeImpl.java | 7 ++-- .../net/httpserver/http2/HTTP2Stream.java | 7 ++++ .../AutoCloseableHttpExchange.java | 5 +-- .../net/httpserver/HttpExchangeAccess.java | 34 ------------------- 9 files changed, 42 insertions(+), 57 deletions(-) delete mode 100644 src/test/java_default/HttpExchange/robaho/net/httpserver/HttpExchangeAccess.java diff --git a/src/main/java/robaho/net/httpserver/AuthFilter.java b/src/main/java/robaho/net/httpserver/AuthFilter.java index 9e40d4f..7da6bf5 100644 --- a/src/main/java/robaho/net/httpserver/AuthFilter.java +++ b/src/main/java/robaho/net/httpserver/AuthFilter.java @@ -28,6 +28,7 @@ import com.sun.net.httpserver.*; import java.io.*; +import java.security.Principal; public class AuthFilter extends Filter { @@ -61,7 +62,7 @@ public void doFilter(HttpExchange t, Filter.Chain chain) throws IOException { Authenticator.Result r = authenticator.authenticate(t); if (r instanceof Authenticator.Success) { Authenticator.Success s = (Authenticator.Success) r; - ExchangeImpl e = ExchangeImpl.get(t); + PrincipalExchange e = (PrincipalExchange)t; e.setPrincipal(s.getPrincipal()); chain.doFilter(t); } else if (r instanceof Authenticator.Retry) { @@ -77,4 +78,9 @@ public void doFilter(HttpExchange t, Filter.Chain chain) throws IOException { chain.doFilter(t); } } + + static interface PrincipalExchange { + public void setPrincipal(HttpPrincipal p); + public Principal getPrincipal(); + } } diff --git a/src/main/java/robaho/net/httpserver/ExchangeImpl.java b/src/main/java/robaho/net/httpserver/ExchangeImpl.java index 3538437..66e366d 100644 --- a/src/main/java/robaho/net/httpserver/ExchangeImpl.java +++ b/src/main/java/robaho/net/httpserver/ExchangeImpl.java @@ -433,15 +433,6 @@ public HttpPrincipal getPrincipal() { void setPrincipal(HttpPrincipal principal) { this.principal = principal; } - - static ExchangeImpl get(HttpExchange t) { - if (t instanceof HttpExchangeImpl) { - return ((HttpExchangeImpl) t).getExchangeImpl(); - } else { - assert t instanceof HttpsExchangeImpl; - return ((HttpsExchangeImpl) t).getExchangeImpl(); - } - } } /** diff --git a/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java b/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java index 1660d4a..8acd76c 100644 --- a/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java +++ b/src/main/java/robaho/net/httpserver/Http2ExchangeImpl.java @@ -6,14 +6,16 @@ import java.net.InetSocketAddress; import java.net.URI; +import javax.net.ssl.SSLSession; + import com.sun.net.httpserver.Headers; import com.sun.net.httpserver.HttpContext; -import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpPrincipal; +import com.sun.net.httpserver.HttpsExchange; import robaho.net.httpserver.http2.HTTP2Stream; -public class Http2ExchangeImpl extends HttpExchange { +public class Http2ExchangeImpl extends HttpsExchange implements AuthFilter.PrincipalExchange { private final Headers request; private final Headers response; private final InputStream in; @@ -21,7 +23,7 @@ public class Http2ExchangeImpl extends HttpExchange { private final URI uri; private final String method; private final HttpContext ctx; - private final HTTP2Stream stream; + protected final HTTP2Stream stream; private HttpPrincipal principal; private int responseCode; @@ -130,4 +132,14 @@ public void setStreams(InputStream i, OutputStream o) { public HttpPrincipal getPrincipal() { return principal; } + + @Override + public SSLSession getSSLSession() { + return stream.getSSLSession(); + } + + @Override + public void setPrincipal(HttpPrincipal p) { + principal = p; + } } diff --git a/src/main/java/robaho/net/httpserver/HttpConnection.java b/src/main/java/robaho/net/httpserver/HttpConnection.java index e7f1a9e..40149a7 100644 --- a/src/main/java/robaho/net/httpserver/HttpConnection.java +++ b/src/main/java/robaho/net/httpserver/HttpConnection.java @@ -78,7 +78,7 @@ public void debug() { logger.log(Level.INFO,toString()+", inRequest "+inRequest+", request count "+requestCount.get()); } - SSLSession getSSLSession() { + public SSLSession getSSLSession() { return (socket instanceof SSLSocket ssl) ? ssl.getHandshakeSession() : null; } diff --git a/src/main/java/robaho/net/httpserver/HttpExchangeImpl.java b/src/main/java/robaho/net/httpserver/HttpExchangeImpl.java index c7e6138..e43cd7d 100644 --- a/src/main/java/robaho/net/httpserver/HttpExchangeImpl.java +++ b/src/main/java/robaho/net/httpserver/HttpExchangeImpl.java @@ -27,9 +27,10 @@ import java.io.*; import java.net.*; + import com.sun.net.httpserver.*; -class HttpExchangeImpl extends HttpExchange { +class HttpExchangeImpl extends HttpExchange implements AuthFilter.PrincipalExchange { ExchangeImpl impl; @@ -105,8 +106,8 @@ public void setStreams(InputStream i, OutputStream o) { public HttpPrincipal getPrincipal() { return impl.getPrincipal(); } - - ExchangeImpl getExchangeImpl() { - return impl; + @Override + public void setPrincipal(HttpPrincipal p) { + impl.setPrincipal(p); } } diff --git a/src/main/java/robaho/net/httpserver/HttpsExchangeImpl.java b/src/main/java/robaho/net/httpserver/HttpsExchangeImpl.java index 49b5646..71548c0 100644 --- a/src/main/java/robaho/net/httpserver/HttpsExchangeImpl.java +++ b/src/main/java/robaho/net/httpserver/HttpsExchangeImpl.java @@ -37,7 +37,7 @@ import com.sun.net.httpserver.HttpPrincipal; import com.sun.net.httpserver.HttpsExchange; -class HttpsExchangeImpl extends HttpsExchange { +class HttpsExchangeImpl extends HttpsExchange implements AuthFilter.PrincipalExchange { ExchangeImpl impl; @@ -118,7 +118,8 @@ public HttpPrincipal getPrincipal() { return impl.getPrincipal(); } - ExchangeImpl getExchangeImpl() { - return impl; + @Override + public void setPrincipal(HttpPrincipal p) { + impl.setPrincipal(p); } } diff --git a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java index e9af7c6..f2b201a 100644 --- a/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java +++ b/src/main/java/robaho/net/httpserver/http2/HTTP2Stream.java @@ -13,6 +13,8 @@ import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.locks.LockSupport; +import javax.net.ssl.SSLSession; + import com.sun.net.httpserver.Headers; import robaho.net.httpserver.NoSyncBufferedOutputStream; @@ -106,10 +108,15 @@ public void debug() { public boolean isOpen() { return streamOpen; } + public boolean isHalfClosed() { return halfClosed; } + public SSLSession getSSLSession() { + return connection.httpConnection.getSSLSession(); + } + private long expectedSize() { if(requestHeaders.containsKey("Content-length")) { return Long.parseLong(requestHeaders.getFirst("Content-length")); diff --git a/src/test/java_default/HttpExchange/AutoCloseableHttpExchange.java b/src/test/java_default/HttpExchange/AutoCloseableHttpExchange.java index 4a73bd4..10721e9 100644 --- a/src/test/java_default/HttpExchange/AutoCloseableHttpExchange.java +++ b/src/test/java_default/HttpExchange/AutoCloseableHttpExchange.java @@ -32,7 +32,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import jdk.test.lib.net.URIBuilder; -import robaho.net.httpserver.HttpExchangeAccess; /** * @test @@ -67,8 +66,10 @@ public void handle(HttpExchange t) throws IOException { ; t.sendResponseHeaders(200, -1); } - if (!HttpExchangeAccess.isClosed(t)) { + try { + t.getResponseBody().write("somedata".getBytes()); exchangeCloseFail.set(true); + } catch (IOException expected) { } latch.countDown(); } diff --git a/src/test/java_default/HttpExchange/robaho/net/httpserver/HttpExchangeAccess.java b/src/test/java_default/HttpExchange/robaho/net/httpserver/HttpExchangeAccess.java deleted file mode 100644 index 9495dc0..0000000 --- a/src/test/java_default/HttpExchange/robaho/net/httpserver/HttpExchangeAccess.java +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2019, Oracle and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Oracle designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA - * or visit www.oracle.com if you need additional information or have any - * questions. - */ - -package robaho.net.httpserver; - -public class HttpExchangeAccess { - public static boolean isClosed(com.sun.net.httpserver.HttpExchange exch) { - synchronized (exch) { - return ((HttpExchangeImpl) exch).getExchangeImpl().closed; - } - } -} From 03fb0d12f2ea48ecd69424641340e1b61973dc9c Mon Sep 17 00:00:00 2001 From: robert engels Date: Sun, 12 Jan 2025 11:24:44 -0600 Subject: [PATCH 51/78] update README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6160bc6..f1471ce 100644 --- a/README.md +++ b/README.md @@ -88,11 +88,11 @@ All logging is performed using the [Java System Logger](https://docs.oracle.com/ ## performance -This verson performs more than **10x** better than the JDK version when tested using the [Tech Empower Benchmarks](https://github.com/TechEmpower/FrameworkBenchmarks/tree/master/frameworks/Java/httpserver) on an identical hardware/work setup with the same JDK 23 version.1 +This version performs more than **10x** faster than the JDK version when tested using the [Tech Empower Benchmarks](https://github.com/TechEmpower/FrameworkBenchmarks/tree/master/frameworks/Java/httpserver) on an identical hardware/work setup with the same JDK 23 version.1 The frameworks were also tested using [go-wrk](https://github.com/tsliwowicz/go-wrk)2 -1_The [robaho version](https://github.com/TechEmpower/FrameworkBenchmarks/tree/master/frameworks/Java/httpserver-robaho) has been submitted to the Tech Empower benchmarks project for 3-party confirmation._
+1_The robaho version has been [submitted](https://github.com/TechEmpower/FrameworkBenchmarks/tree/master/frameworks/Java/httpserver-robaho) to the Tech Empower benchmarks project for 3-party confirmation._
2_`go-wrk` does not use http pipelining so, the large number of connections is the limiting factor._ Performance tests against the latest Jetty version were run. The `robaho httpserver` outperformed the Jetty http2 by 3x, and http1 by 5x. From d06da855ca6d9e7cf665f53d415f20ffc56cf352 Mon Sep 17 00:00:00 2001 From: Josiah Noel <32279667+SentryMan@users.noreply.github.com> Date: Sun, 12 Jan 2025 23:01:16 -0500 Subject: [PATCH 52/78] [Docs] Move up maven coords and http2 config --- README.md | 41 ++++++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index f1471ce..01491e1 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ + # 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. @@ -31,6 +32,17 @@ Additional proxy and websockets tests are included. The http2 implementation passes all specification tests in [h2spec](https://github.com/summerwind/h2spec) +## maven +[![Maven Central](https://img.shields.io/maven-central/v/io.github.robaho/httpserver.svg?label=Maven%20Central)](https://mvnrepository.com/artifact/io.avaje/avaje-jex) + +```xml + + io.github.robaho + httpserver + ${version} + +``` + ## using The JDK will automatically use `robaho.net.httpserver.DefaultHttpServerProvider` instead of the JDK implementation when the jar is placed on the class/module path. If there are multiple `HttpServer` providers on the classpath, the `com.sun.net.httpserver.HttpServerProvider` system property can be used to specify the correct one: @@ -86,6 +98,16 @@ gradle runSimpleFileServer 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) +## enable Http2 + +Http2 support is enabled via Java system properties. + +Use `-Drobaho.net.httpserver.http2OverSSL=true` to enable Http2 only via SSL connections. + +Use `-Drobaho.net.httpserver.http2OverNonSSL=true` to enable Http2 on Non-SSL connections (which requires prior knowledge). The Http2 upgrade mechanism was deprecated in RFC 9113 so it is not supported. + +See the additional Http2 options in `ServerConfig.java` + ## performance This version performs more than **10x** faster than the JDK version when tested using the [Tech Empower Benchmarks](https://github.com/TechEmpower/FrameworkBenchmarks/tree/master/frameworks/Java/httpserver) on an identical hardware/work setup with the same JDK 23 version.1 @@ -288,25 +310,6 @@ Reply Errors: 0 The counts and rates for non "Total" statistics are reset with each pull of the statistics. -## maven - -```xml - - io.github.robaho - httpserver - 1.0.19 - -``` -## enable Http2 - -Http2 support is enabled via Java system properties. - -Use `-Drobaho.net.httpserver.http2OverSSL=true` to enable Http2 only via SSL connections. - -Use `-Drobaho.net.httpserver.http2OverNonSSL=true` to enable Http2 on Non-SSL connections (which requires prior knowledge). The Http2 upgrade mechanism was deprecated in RFC 9113 so it is not supported. - -See the additional Http2 options in `ServerConfig.java` - ## performance notes Http2 performance has not been fully optimized. The http2 version is about 20-30% slower than http1. I expect this to be the case with most http2 implementations due to the complexity. From 58b311c18c1d7e119fc90432f4e2a7bf1556ba55 Mon Sep 17 00:00:00 2001 From: Josiah Noel <32279667+SentryMan@users.noreply.github.com> Date: Sun, 12 Jan 2025 23:09:11 -0500 Subject: [PATCH 53/78] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 01491e1..1007b3a 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ Additional proxy and websockets tests are included. The http2 implementation passes all specification tests in [h2spec](https://github.com/summerwind/h2spec) ## maven -[![Maven Central](https://img.shields.io/maven-central/v/io.github.robaho/httpserver.svg?label=Maven%20Central)](https://mvnrepository.com/artifact/io.avaje/avaje-jex) +[![Maven Central](https://img.shields.io/maven-central/v/io.github.robaho/httpserver.svg?label=Maven%20Central)](https://mvnrepository.com/artifact/io.github.robaho/httpserver) ```xml From 9be9abee992c2ef5413df2fbfdaf89a4f2583623 Mon Sep 17 00:00:00 2001 From: robert engels Date: Mon, 13 Jan 2025 15:08:09 -0600 Subject: [PATCH 54/78] add basic Http2 test (disabled) --- .../net/httpserver/http2/Http2Test.java | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/test/java/robaho/net/httpserver/http2/Http2Test.java diff --git a/src/test/java/robaho/net/httpserver/http2/Http2Test.java b/src/test/java/robaho/net/httpserver/http2/Http2Test.java new file mode 100644 index 0000000..517585d --- /dev/null +++ b/src/test/java/robaho/net/httpserver/http2/Http2Test.java @@ -0,0 +1,89 @@ +package robaho.net.httpserver.http2; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpClient.Version; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse.BodyHandlers; +import java.util.concurrent.Executors; +import java.util.logging.ConsoleHandler; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.testng.Assert; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import com.sun.net.httpserver.HttpContext; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; + +import robaho.net.httpserver.Http2ExchangeImpl; +import robaho.net.httpserver.LoggingFilter; + +@Test(enabled=false) // this is disabled since JDK HttpClient cannot perform "prior knowledge" Http2 connections over non-SSL + +public class Http2Test { + + static { + // System.setProperty("jdk.httpclient.HttpClient.log", "all"); + // System.setProperty("jdk.internal.httpclient.websocket.debug", "true"); + } + + private static final int port = 9000; + private static final String path = "/echo"; + + HttpServer server; + + private volatile boolean foundHttp2 = false; + + @BeforeMethod + public void setUp() throws IOException { + Logger logger = Logger.getLogger(Http2Test.class.getName()); + ConsoleHandler ch = new ConsoleHandler(); + logger.setLevel(Level.ALL); + ch.setLevel(Level.ALL); + logger.addHandler(ch); + + server = HttpServer.create(new InetSocketAddress(port), 0); + HttpHandler h = new EchoHandler(); + HttpContext c = server.createContext(path, h); + c.getFilters().add(new LoggingFilter(logger)); + server.setExecutor(Executors.newCachedThreadPool()); + server.start(); + } + + @AfterMethod + public void tearDown() { + server.stop(0); + } + + @Test + public void testHttp2Request() throws InterruptedException, IOException, URISyntaxException { + var client = HttpClient.newBuilder().version(Version.HTTP_2).build(); + var request = HttpRequest.newBuilder(new URI("http://localhost:9000"+path)).POST(HttpRequest.BodyPublishers.ofString("This is a test")).build(); + var response = client.send(request,BodyHandlers.ofString()); + Assert.assertEquals(response.body(),"This is a test"); + Assert.assertTrue(foundHttp2); + } + + private class EchoHandler implements HttpHandler { + + @Override + public void handle(HttpExchange he) throws IOException { + if(he instanceof Http2ExchangeImpl) { + foundHttp2 = true; + } + he.sendResponseHeaders(200,0); + try (var exchange = he) { + exchange.getRequestBody().transferTo(exchange.getResponseBody()); + } + } + + } +} From c7887ea66c9ad1012205dd92b6d0a0e0bfb6f6d8 Mon Sep 17 00:00:00 2001 From: Josiah Noel <32279667+SentryMan@users.noreply.github.com> Date: Mon, 13 Jan 2025 16:14:49 -0500 Subject: [PATCH 55/78] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1007b3a..359ee49 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ The http2 implementation passes all specification tests in [h2spec](https://gith io.github.robaho httpserver - ${version} + ${the.above.version} ``` From 52332ebc44f8de252344ffac29feae1b925148ce Mon Sep 17 00:00:00 2001 From: Josiah Noel <32279667+SentryMan@users.noreply.github.com> Date: Mon, 13 Jan 2025 18:52:49 -0500 Subject: [PATCH 56/78] hardcode version --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 359ee49..fa26409 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ The http2 implementation passes all specification tests in [h2spec](https://gith io.github.robaho httpserver - ${the.above.version} + 1.0.19 ``` From a306c7c5cde8fa05ba1a6217dfbfbfe56a8853d8 Mon Sep 17 00:00:00 2001 From: robert engels Date: Wed, 15 Jan 2025 13:03:53 -0600 Subject: [PATCH 57/78] change back to use version labelling to avoid the need to update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fa26409..1ab26e7 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ The http2 implementation passes all specification tests in [h2spec](https://gith io.github.robaho httpserver - 1.0.19 + use version from badge above without leading v ``` From 2e7f5807f4fec3f5622ff3fd870549302161d786 Mon Sep 17 00:00:00 2001 From: robert engels Date: Thu, 16 Jan 2025 10:53:34 -0600 Subject: [PATCH 58/78] disable unit test, since HttpClient does not support "http2 prior knowledge" --- src/test/java/robaho/net/httpserver/http2/Http2Test.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/test/java/robaho/net/httpserver/http2/Http2Test.java b/src/test/java/robaho/net/httpserver/http2/Http2Test.java index 517585d..c593630 100644 --- a/src/test/java/robaho/net/httpserver/http2/Http2Test.java +++ b/src/test/java/robaho/net/httpserver/http2/Http2Test.java @@ -27,7 +27,6 @@ import robaho.net.httpserver.LoggingFilter; @Test(enabled=false) // this is disabled since JDK HttpClient cannot perform "prior knowledge" Http2 connections over non-SSL - public class Http2Test { static { @@ -63,7 +62,7 @@ public void tearDown() { server.stop(0); } - @Test + @Test(enabled=false) public void testHttp2Request() throws InterruptedException, IOException, URISyntaxException { var client = HttpClient.newBuilder().version(Version.HTTP_2).build(); var request = HttpRequest.newBuilder(new URI("http://localhost:9000"+path)).POST(HttpRequest.BodyPublishers.ofString("This is a test")).build(); From 4d2c7b653780c1db5830f00b339a5fe15af68e79 Mon Sep 17 00:00:00 2001 From: robert engels Date: Thu, 16 Jan 2025 10:54:09 -0600 Subject: [PATCH 59/78] fix issue #15 HttpsConfigurator.configure method not executed for https connections --- .../net/httpserver/SSLConfigurator.java | 48 +++++++++++++++++++ .../robaho/net/httpserver/ServerImpl.java | 6 +-- 2 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 src/main/java/robaho/net/httpserver/SSLConfigurator.java diff --git a/src/main/java/robaho/net/httpserver/SSLConfigurator.java b/src/main/java/robaho/net/httpserver/SSLConfigurator.java new file mode 100644 index 0000000..8cdbf6e --- /dev/null +++ b/src/main/java/robaho/net/httpserver/SSLConfigurator.java @@ -0,0 +1,48 @@ +package robaho.net.httpserver; + +import java.net.InetSocketAddress; + +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; + +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; + +class SSLConfigurator { + static void configure(SSLSocket s,HttpsConfigurator cfg) { + s.setUseClientMode(false); + if(cfg==null) return; + InetSocketAddress remoteAddress = (InetSocketAddress)s.getRemoteSocketAddress(); + Parameters params = new Parameters (cfg, remoteAddress); + cfg.configure(params); + SSLParameters sslParams = params.getSSLParameters(); + if (sslParams != null) { + s.setSSLParameters(sslParams); + } + } + static class Parameters extends HttpsParameters { + InetSocketAddress addr; + HttpsConfigurator cfg; + + Parameters (HttpsConfigurator cfg, InetSocketAddress addr) { + this.addr = addr; + this.cfg = cfg; + } + @Override + public InetSocketAddress getClientAddress () { + return addr; + } + @Override + public HttpsConfigurator getHttpsConfigurator() { + return cfg; + } + SSLParameters params; + @Override + public void setSSLParameters (SSLParameters p) { + params = p; + } + SSLParameters getSSLParameters () { + return params; + } + } +} \ No newline at end of file diff --git a/src/main/java/robaho/net/httpserver/ServerImpl.java b/src/main/java/robaho/net/httpserver/ServerImpl.java index b91d038..78cee4d 100644 --- a/src/main/java/robaho/net/httpserver/ServerImpl.java +++ b/src/main/java/robaho/net/httpserver/ServerImpl.java @@ -24,7 +24,6 @@ */ package robaho.net.httpserver; -import java.io.EOFException; import static java.nio.charset.StandardCharsets.ISO_8859_1; import java.io.IOException; @@ -63,6 +62,7 @@ import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; import robaho.net.httpserver.http2.HTTP2Connection; import robaho.net.httpserver.http2.HTTP2ErrorCode; @@ -370,6 +370,8 @@ public void run() { // 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"; @@ -377,10 +379,8 @@ public void run() { return "http/1.1"; } }); - sslSocket.setUseClientMode(false); // 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; From 8890561a441f0dcfb47992141009565663d5fadc Mon Sep 17 00:00:00 2001 From: robert engels Date: Thu, 16 Jan 2025 18:08:01 -0600 Subject: [PATCH 60/78] test case for issue #15 HttpsConfigurator.configure method not executed for https connections --- .../robaho/net/httpserver/ServerImpl.java | 1 - .../net/httpserver/HttpsConfiguratorTest.java | 77 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 src/test/java/robaho/net/httpserver/HttpsConfiguratorTest.java diff --git a/src/main/java/robaho/net/httpserver/ServerImpl.java b/src/main/java/robaho/net/httpserver/ServerImpl.java index 78cee4d..0cf7fd9 100644 --- a/src/main/java/robaho/net/httpserver/ServerImpl.java +++ b/src/main/java/robaho/net/httpserver/ServerImpl.java @@ -62,7 +62,6 @@ import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpsConfigurator; -import com.sun.net.httpserver.HttpsParameters; import robaho.net.httpserver.http2.HTTP2Connection; import robaho.net.httpserver.http2.HTTP2ErrorCode; diff --git a/src/test/java/robaho/net/httpserver/HttpsConfiguratorTest.java b/src/test/java/robaho/net/httpserver/HttpsConfiguratorTest.java new file mode 100644 index 0000000..f649ffc --- /dev/null +++ b/src/test/java/robaho/net/httpserver/HttpsConfiguratorTest.java @@ -0,0 +1,77 @@ +package robaho.net.httpserver; + +import org.testng.annotations.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URISyntaxException; +import java.net.URL; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSession; + +import static org.testng.Assert.assertTrue; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; + +import jdk.test.lib.net.SimpleSSLContext; +import jdk.test.lib.net.URIBuilder; + +public class HttpsConfiguratorTest { + volatile boolean configureCalled = false; + @Test + public void TestConfigureCalled() throws IOException, URISyntaxException { + int port = 8443; + + var httpsServer = HttpsServer.create(new InetSocketAddress(port), 8192); + var ctx = new SimpleSSLContext().get(); + httpsServer.setHttpsConfigurator(new MyHttpsConfigurator (ctx)); + httpsServer.createContext("/test", (HttpExchange he) -> { + he.sendResponseHeaders(200,0); + try (var os = he.getResponseBody()) { + os.write("Hello".getBytes()); + } + }); + httpsServer.start(); + try { + URL url = URIBuilder.newBuilder() + .scheme("https") + .loopback() + .port(httpsServer.getAddress().getPort()) + .path("/test") + .toURL(); + HttpsURLConnection urlc = (HttpsURLConnection)url.openConnection(Proxy.NO_PROXY); + urlc.setSSLSocketFactory (ctx.getSocketFactory()); + urlc.setHostnameVerifier (new DummyVerifier()); + urlc.getInputStream().readAllBytes(); + assertTrue(urlc.getResponseCode()==200); + } finally { + httpsServer.stop(0); + } + assertTrue(configureCalled); + } + + class MyHttpsConfigurator extends HttpsConfigurator { + public MyHttpsConfigurator(SSLContext context) { + super(context); + } + + @Override + public void configure(HttpsParameters params) { + super.configure(params); + configureCalled = true; + } + } + public class DummyVerifier implements HostnameVerifier { + @Override + public boolean verify(String s, SSLSession s1) { + return true; + } + } +} From 8355144560afd098571375ec2f098448c8d69516 Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 17 Jan 2025 07:59:47 -0600 Subject: [PATCH 61/78] test case for issue #16 unable to get SSL session from exchange --- .../robaho/net/httpserver/SSLSessionTest.java | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/test/java/robaho/net/httpserver/SSLSessionTest.java diff --git a/src/test/java/robaho/net/httpserver/SSLSessionTest.java b/src/test/java/robaho/net/httpserver/SSLSessionTest.java new file mode 100644 index 0000000..6d93c8f --- /dev/null +++ b/src/test/java/robaho/net/httpserver/SSLSessionTest.java @@ -0,0 +1,79 @@ +package robaho.net.httpserver; + +import org.testng.annotations.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URISyntaxException; +import java.net.URL; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSession; + +import static org.testng.Assert.assertTrue; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsExchange; +import com.sun.net.httpserver.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; + +import jdk.test.lib.net.SimpleSSLContext; +import jdk.test.lib.net.URIBuilder; + +public class SSLSessionTest { + volatile boolean hasSslSession = false; + @Test + public void TestConfigureCalled() throws IOException, URISyntaxException { + int port = 8443; + + var httpsServer = HttpsServer.create(new InetSocketAddress(port), 8192); + var ctx = new SimpleSSLContext().get(); + httpsServer.setHttpsConfigurator(new MyHttpsConfigurator (ctx)); + httpsServer.createContext("/test", (HttpExchange he) -> { + var sslSession = (HttpsExchange)he; + hasSslSession = sslSession!=null; + he.sendResponseHeaders(200,0); + try (var os = he.getResponseBody()) { + os.write("Hello".getBytes()); + } + }); + httpsServer.start(); + try { + URL url = URIBuilder.newBuilder() + .scheme("https") + .loopback() + .port(httpsServer.getAddress().getPort()) + .path("/test") + .toURL(); + HttpsURLConnection urlc = (HttpsURLConnection)url.openConnection(Proxy.NO_PROXY); + urlc.setSSLSocketFactory (ctx.getSocketFactory()); + urlc.setHostnameVerifier (new DummyVerifier()); + urlc.getInputStream().readAllBytes(); + assertTrue(urlc.getResponseCode()==200); + } finally { + httpsServer.stop(0); + } + assertTrue(hasSslSession); + } + + class MyHttpsConfigurator extends HttpsConfigurator { + public MyHttpsConfigurator(SSLContext context) { + super(context); + } + + @Override + public void configure(HttpsParameters params) { + super.configure(params); + } + } + public class DummyVerifier implements HostnameVerifier { + @Override + public boolean verify(String s, SSLSession s1) { + return true; + } + } +} From 701d5b1b3ffd4bd6f945dc48b9fdf467bbc69326 Mon Sep 17 00:00:00 2001 From: robert engels Date: Fri, 17 Jan 2025 07:59:47 -0600 Subject: [PATCH 62/78] test case for issue #16 unable to get SSL session from exchange --- .../robaho/net/httpserver/SSLSessionTest.java | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/test/java/robaho/net/httpserver/SSLSessionTest.java diff --git a/src/test/java/robaho/net/httpserver/SSLSessionTest.java b/src/test/java/robaho/net/httpserver/SSLSessionTest.java new file mode 100644 index 0000000..f84ee43 --- /dev/null +++ b/src/test/java/robaho/net/httpserver/SSLSessionTest.java @@ -0,0 +1,79 @@ +package robaho.net.httpserver; + +import org.testng.annotations.Test; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.URISyntaxException; +import java.net.URL; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSession; + +import static org.testng.Assert.assertTrue; + +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsExchange; +import com.sun.net.httpserver.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; + +import jdk.test.lib.net.SimpleSSLContext; +import jdk.test.lib.net.URIBuilder; + +public class SSLSessionTest { + volatile boolean hasSslSession = false; + @Test + public void TestHasSslSession() throws IOException, URISyntaxException { + int port = 8443; + + var httpsServer = HttpsServer.create(new InetSocketAddress(port), 8192); + var ctx = new SimpleSSLContext().get(); + httpsServer.setHttpsConfigurator(new MyHttpsConfigurator (ctx)); + httpsServer.createContext("/test", (HttpExchange he) -> { + var sslSession = (HttpsExchange)he; + hasSslSession = sslSession!=null; + he.sendResponseHeaders(200,0); + try (var os = he.getResponseBody()) { + os.write("Hello".getBytes()); + } + }); + httpsServer.start(); + try { + URL url = URIBuilder.newBuilder() + .scheme("https") + .loopback() + .port(httpsServer.getAddress().getPort()) + .path("/test") + .toURL(); + HttpsURLConnection urlc = (HttpsURLConnection)url.openConnection(Proxy.NO_PROXY); + urlc.setSSLSocketFactory (ctx.getSocketFactory()); + urlc.setHostnameVerifier (new DummyVerifier()); + urlc.getInputStream().readAllBytes(); + assertTrue(urlc.getResponseCode()==200); + } finally { + httpsServer.stop(0); + } + assertTrue(hasSslSession); + } + + class MyHttpsConfigurator extends HttpsConfigurator { + public MyHttpsConfigurator(SSLContext context) { + super(context); + } + + @Override + public void configure(HttpsParameters params) { + super.configure(params); + } + } + public class DummyVerifier implements HostnameVerifier { + @Override + public boolean verify(String s, SSLSession s1) { + return true; + } + } +} From 2a13b2a3b80a148de4309cda948e93bb55881c51 Mon Sep 17 00:00:00 2001 From: viretp Date: Thu, 30 Jan 2025 17:43:15 +0100 Subject: [PATCH 63/78] 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 64/78] 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 65/78] 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 66/78] 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 67/78] 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 68/78] 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 69/78] 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 70/78] 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 71/78] 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 72/78] 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 73/78] 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 74/78] 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 75/78] 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 76/78] 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 77/78] 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 78/78] 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."); + } +}