diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/HttpInitializer.java b/webserver/webserver/src/main/java/io/helidon/webserver/HttpInitializer.java index 6f83dff477e..392b7ee5839 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/HttpInitializer.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/HttpInitializer.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017, 2022 Oracle and/or its affiliates. + * Copyright (c) 2017, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -33,6 +33,8 @@ import io.helidon.webserver.ReferenceHoldingQueue.IndirectReference; import io.netty.channel.Channel; +import io.netty.channel.ChannelDuplexHandler; +import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelPipeline; import io.netty.channel.socket.SocketChannel; @@ -42,6 +44,8 @@ import io.netty.handler.logging.LoggingHandler; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.SslHandler; +import io.netty.handler.timeout.IdleStateEvent; +import io.netty.handler.timeout.IdleStateHandler; import io.netty.util.AttributeKey; import io.netty.util.concurrent.Future; @@ -193,6 +197,20 @@ public void initChannel(SocketChannel ch) { directHandlers)); } + // Set up idle handler to close inactive connections based on config + int idleTimeout = serverConfig.connectionIdleTimeout(); + if (idleTimeout > 0) { + p.addLast(new IdleStateHandler(idleTimeout, idleTimeout, idleTimeout)); + p.addLast(new ChannelDuplexHandler() { + @Override + public void userEventTriggered(ChannelHandlerContext ctx, Object evt) { + if (evt instanceof IdleStateEvent) { + ctx.close(); // async close of idle connection + } + } + }); + } + // Cleanup queues as part of event loop ch.eventLoop().execute(this::clearQueues); } diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/ServerBasicConfig.java b/webserver/webserver/src/main/java/io/helidon/webserver/ServerBasicConfig.java index b76cca4e127..67716310996 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/ServerBasicConfig.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/ServerBasicConfig.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017, 2023 Oracle and/or its affiliates. + * Copyright (c) 2017, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -208,6 +208,11 @@ public boolean requestedUriDiscoveryEnabled() { return isRequestedUriDiscoveryEnabled; } + @Override + public int connectionIdleTimeout() { + return socketConfig.connectionIdleTimeout(); + } + static class SocketConfig implements SocketConfiguration { private final int port; @@ -232,6 +237,7 @@ static class SocketConfig implements SocketConfiguration { private final List requestedUriDiscoveryTypes; private final AllowList trustedProxies; private final boolean isRequestedUriDiscoveryEnabled; + private final int connectionIdleTimeout; /** * Creates new instance. @@ -260,6 +266,7 @@ static class SocketConfig implements SocketConfiguration { this.requestedUriDiscoveryTypes = builder.requestedUriDiscoveryTypes(); this.trustedProxies = builder.trustedProxies(); this.isRequestedUriDiscoveryEnabled = builder.requestedUriDiscoveryEnabled(); + this.connectionIdleTimeout = builder.connectionIdleTimeout(); } @Override @@ -395,5 +402,10 @@ public AllowList trustedProxies() { public boolean requestedUriDiscoveryEnabled() { return isRequestedUriDiscoveryEnabled; } + + @Override + public int connectionIdleTimeout() { + return connectionIdleTimeout; + } } } diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/ServerConfiguration.java b/webserver/webserver/src/main/java/io/helidon/webserver/ServerConfiguration.java index 7e85a5ee646..c9e52896488 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/ServerConfiguration.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/ServerConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017, 2023 Oracle and/or its affiliates. + * Copyright (c) 2017, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -606,6 +606,12 @@ public Builder trustedProxies(AllowList trustedProxies) { return this; } + @Override + public Builder connectionIdleTimeout(int seconds) { + defaultSocketBuilder().connectionIdleTimeout(seconds); + return this; + } + /** * Configure the maximum amount of time that the server will wait to shut * down regardless of the value of any additionally requested diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/SocketConfiguration.java b/webserver/webserver/src/main/java/io/helidon/webserver/SocketConfiguration.java index 09815e5f70b..28d9d20c044 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/SocketConfiguration.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/SocketConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017, 2023 Oracle and/or its affiliates. + * Copyright (c) 2017, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -296,6 +296,16 @@ default int maxUpgradeContentLength() { return 64 * 1024; } + /** + * Timeout seconds after which any idle connection will be automatically closed + * by the server. + * + * @return idle connection timeout in seconds + */ + default int connectionIdleTimeout() { + return 0; + } + /** * Types of discovery of frontend uri. Defaults to {@link #HOST} when frontend uri discovery is disabled (uses only Host * header and information about current request to determine scheme, host, port, and path). @@ -587,6 +597,15 @@ default B tls(Supplier tlsConfig) { @ConfiguredOption(key = REQUESTED_URI_DISCOVERY_CONFIG_KEY_PREFIX + "trusted-proxies") B trustedProxies(AllowList trustedProxies); + /** + * Sets the number of seconds after which an idle connection will be automatically + * closed by the server. + * + * @param seconds time in seconds + * @return updated builder + */ + B connectionIdleTimeout(int seconds); + /** * Update this socket configuration from a {@link io.helidon.config.Config}. * @@ -639,6 +658,9 @@ default B config(Config config) { config.get("requested-uri-discovery.trusted-proxies").as(AllowList::create) .ifPresent(this::trustedProxies); + // idle connections + config.get("connection-idle-timeout").asInt().ifPresent(this::connectionIdleTimeout); + return (B) this; } } @@ -683,6 +705,7 @@ final class Builder implements SocketConfigurationBuilder, io.helidon.c private final List requestedUriDiscoveryTypes = new ArrayList<>(); private Boolean requestedUriDiscoveryEnabled; private AllowList trustedProxies; + private int connectionIdleTimeout; private Builder() { } @@ -977,6 +1000,7 @@ public Builder config(Config config) { config.get("enable-compression").asBoolean().ifPresent(this::enableCompression); config.get("backpressure-buffer-size").asLong().ifPresent(this::backpressureBufferSize); config.get("backpressure-strategy").as(BackpressureStrategy.class).ifPresent(this::backpressureStrategy); + config.get("connection-idle-timeout").asInt().ifPresent(this::connectionIdleTimeout); return this; } @@ -1000,6 +1024,12 @@ public Builder requestedUriDiscoveryEnabled(boolean enabled) { return this; } + @Override + public Builder connectionIdleTimeout(int seconds) { + this.connectionIdleTimeout = seconds; + return this; + } + int port() { return port; } @@ -1091,6 +1121,10 @@ boolean requestedUriDiscoveryEnabled() { return requestedUriDiscoveryEnabled; } + int connectionIdleTimeout() { + return connectionIdleTimeout; + } + /** * Checks validity of requested URI settings and supplies defaults for omitted settings. *

The behavior of `requested-uri-discovery` settings can be summarized as follows: diff --git a/webserver/webserver/src/main/java/io/helidon/webserver/WebServer.java b/webserver/webserver/src/main/java/io/helidon/webserver/WebServer.java index 830d2ffb897..8011466bcf8 100644 --- a/webserver/webserver/src/main/java/io/helidon/webserver/WebServer.java +++ b/webserver/webserver/src/main/java/io/helidon/webserver/WebServer.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2017, 2023 Oracle and/or its affiliates. + * Copyright (c) 2017, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -639,6 +639,12 @@ public Builder trustedProxies(AllowList trustedProxies) { return this; } + @Override + public Builder connectionIdleTimeout(int seconds) { + defaultSocket(it -> it.connectionIdleTimeout(seconds)); + return this; + } + /** * A helper method to support fluentAPI when invoking another method. *

diff --git a/webserver/webserver/src/test/java/io/helidon/webserver/ConnectionIdleTest.java b/webserver/webserver/src/test/java/io/helidon/webserver/ConnectionIdleTest.java new file mode 100644 index 00000000000..e678923efd9 --- /dev/null +++ b/webserver/webserver/src/test/java/io/helidon/webserver/ConnectionIdleTest.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver; + +import java.net.SocketException; +import java.time.Duration; +import java.util.List; +import java.util.logging.Logger; + +import io.helidon.common.http.Http; +import io.helidon.webserver.utils.SocketHttpClient; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.MatcherAssert.assertThat; + +/** + * Tests support for idle connection timeouts. + */ +public class ConnectionIdleTest { + private static final Logger LOGGER = Logger.getLogger(ConnectionIdleTest.class.getName()); + private static final Duration TIMEOUT = Duration.ofSeconds(10); + + private static final int IDLE_TIMEOUT = 1000; + + private static WebServer webServer; + + @BeforeAll + public static void startServer() throws Exception { + startServer(0); + } + + @AfterAll + public static void close() throws Exception { + if (webServer != null) { + webServer.shutdown().await(TIMEOUT); + } + } + + /** + * Start the Web Server + * + * @param port the port on which to start the server + */ + private static void startServer(int port) { + webServer = WebServer.builder() + .host("localhost") + .port(port) + .connectionIdleTimeout(IDLE_TIMEOUT / 1000) // in seconds + .routing(r -> r.get("/hello", (req, res) -> res.send("Hello World!"))) + .build() + .start() + .await(TIMEOUT); + + LOGGER.info("Started server at: https://localhost:" + webServer.port()); + } + + @Test + public void testIdleConnectionClosed() throws Exception { + try (SocketHttpClient client = new SocketHttpClient(webServer)) { + // initial request with keep-alive to open connection + client.request(Http.Method.GET, + "/hello", + null, + List.of("Connection: keep-alive")); + String res = client.receive(); + assertThat(res, containsString("Hello World!")); + + // second request while connection is active + client.request(Http.Method.GET, + "/hello", + null); + res = client.receive(); + assertThat(res, containsString("Hello World!")); + + // wait for connection to time out due to inactivity + Thread.sleep(2 * IDLE_TIMEOUT); + + // try again and either get nothing or an exception + try { + client.request(Http.Method.GET, + "/hello", + null); + res = client.receive(); + assertThat(res, is("")); + } catch (SocketException e) { + // falls through as possible outcome + } + } + } +}