From 83d5ffc553e8a84b398673bef45cfbf42c135fd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dariusz=20J=C4=99drzejczyk?= Date: Tue, 11 Mar 2025 16:21:19 +0100 Subject: [PATCH 01/44] [release] Back to snapshots, next is 1.2.5-SNAPSHOT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Dariusz Jędrzejczyk --- gradle.properties | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/gradle.properties b/gradle.properties index 182a1e2500..b544c2c18f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ -reactorPoolVersion=1.1.2 -version=1.2.4 -reactorNettyQuicVersion=0.2.4 -reactorCoreVersion=3.7.4 -reactorAddonsVersion=3.5.2 -compatibleVersion=1.2.3 +reactorPoolVersion=1.1.3-SNAPSHOT +version=1.2.5-SNAPSHOT +reactorNettyQuicVersion=0.2.5-SNAPSHOT +reactorCoreVersion=3.7.5-SNAPSHOT +reactorAddonsVersion=3.5.3-SNAPSHOT +compatibleVersion=1.2.4 bomVersion=2024.0.4 From 36f8c2d6d891d4bd691034708e768ef4f1cc05fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Mar 2025 09:23:32 +0200 Subject: [PATCH 02/44] Bump ruby/setup-ruby from 1.222.0 to 1.224.0 (#3673) Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.222.0 to 1.224.0. - [Release notes](https://github.com/ruby/setup-ruby/releases) - [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb) - [Commits](https://github.com/ruby/setup-ruby/compare/277ba2a127aba66d45bad0fa2dc56f80dbfedffa...bbda85882f33075a3727c01e3c8d0de0be6146ce) --- updated-dependencies: - dependency-name: ruby/setup-ruby dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 13a6969f84..22f27f83ac 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -92,7 +92,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Set up Ruby for asciidoctor-pdf - uses: ruby/setup-ruby@277ba2a127aba66d45bad0fa2dc56f80dbfedffa # v1 + uses: ruby/setup-ruby@bbda85882f33075a3727c01e3c8d0de0be6146ce # v1 with: ruby-version: 3.3.0 - name: Install asciidoctor-pdf / rouge From c585d739a7be0387a7ec558635e7f3fd2658081b Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Wed, 12 Mar 2025 17:47:33 +0200 Subject: [PATCH 03/44] Move log statement to the correct place (#3674) Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .../netty/http/client/Http2ConnectionProvider.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2ConnectionProvider.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2ConnectionProvider.java index fac1f82fe0..1c52347b6d 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2ConnectionProvider.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2ConnectionProvider.java @@ -423,15 +423,15 @@ public void operationComplete(Future future) { HttpClientConfig.addStreamHandlers(ch, obs.then(new HttpClientConfig.StreamConnectionObserver(currentContext())), opsFactory, acceptGzip, metricsRecorder, proxyAddress, remoteAddress, -1, uriTagValue); + if (log.isDebugEnabled()) { + logStreamsState(ch, http2PooledRef.slot, "Stream opened"); + } + ChannelOperations ops = ChannelOperations.get(ch); if (ops != null) { obs.onStateChange(ops, STREAM_CONFIGURED); sink.success(ops); } - - if (log.isDebugEnabled()) { - logStreamsState(ch, http2PooledRef.slot, "Stream opened"); - } } } else { From f8db930ae6ac2dda31d7c9bf026e5578f9361f6c Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Thu, 13 Mar 2025 15:14:44 +0200 Subject: [PATCH 04/44] Ensure the HTTP/2 stream is closed when an error happens before send operation (#3675) Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .../netty/http/client/HttpClientConfig.java | 2 +- .../java/reactor/netty/http/Http2Tests.java | 53 ++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConfig.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConfig.java index 9cad58fa57..c79723d02a 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConfig.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConfig.java @@ -1151,7 +1151,7 @@ public Context currentContext() { @SuppressWarnings("FutureReturnValueIgnored") public void onStateChange(Connection connection, State state) { if (state == State.DISCONNECTING) { - if (!connection.isPersistent() && connection.channel().isActive()) { + if (connection.channel().isActive()) { // Will be released by closeFuture // "FutureReturnValueIgnored" this is deliberate connection.channel().close(); diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/Http2Tests.java b/reactor-netty-http/src/test/java/reactor/netty/http/Http2Tests.java index 3e2b8fe3b7..d4b25cf2b2 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/Http2Tests.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/Http2Tests.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 VMware, Inc. or its affiliates, All Rights Reserved. + * Copyright (c) 2020-2025 VMware, Inc. or its affiliates, All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -688,4 +688,55 @@ private void doTestEmptyDataFrame(HttpServer server, HttpClient client) { .expectComplete() .verify(Duration.ofSeconds(10)); } + + @ParameterizedTest + @MethodSource("h2CompatibleCombinations") + void h2ClientSendsError(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols) { + Http2SslContextSpec serverCtx = Http2SslContextSpec.forServer(ssc.certificate(), ssc.privateKey()); + Http2SslContextSpec clientCtx = + Http2SslContextSpec.forClient() + .configure(builder -> builder.trustManager(InsecureTrustManagerFactory.INSTANCE)); + ConnectionProvider provider = ConnectionProvider.create("h2ClientSendsError", 1); + try { + http2ClientSendsError(createServer().protocol(serverProtocols).secure(spec -> spec.sslContext(serverCtx)), + createClient(provider, () -> disposableServer.address()).protocol(clientProtocols).secure(spec -> spec.sslContext(clientCtx))); + } + finally { + provider.disposeLater() + .block(Duration.ofSeconds(5)); + } + } + + private void http2ClientSendsError(HttpServer server, HttpClient client) { + disposableServer = + server.http2Settings(spec -> spec.maxConcurrentStreams(1)) + .handle((req, res) -> Mono.empty()) + .bindNow(); + + Mono content = + client.post() + .uri("/") + .send(Mono.error(new RuntimeException("http2ClientSendsError"))) + .responseContent() + .aggregate() + .asString(); + + List> result = + Flux.range(1, 3) + .flatMapDelayError(i -> content, 256, 32) + .materialize() + .collectList() + .block(Duration.ofSeconds(10)); + + assertThat(result) + .isNotNull() + .hasSize(1) + .allMatch(Signal::hasError); + Throwable error = result.get(0).getThrowable(); + assertThat(error).isNotNull(); + assertThat(error.getSuppressed()) + .isNotNull() + .hasSize(3) + .allMatch(throwable -> "http2ClientSendsError".equals(throwable.getMessage())); + } } From 061f66e0956ea673dd1853b36a2ec76b8316eb63 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Mar 2025 10:02:41 +0200 Subject: [PATCH 05/44] Bump ruby/setup-ruby from 1.224.0 to 1.225.0 (#3676) Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.224.0 to 1.225.0. - [Release notes](https://github.com/ruby/setup-ruby/releases) - [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb) - [Commits](https://github.com/ruby/setup-ruby/compare/bbda85882f33075a3727c01e3c8d0de0be6146ce...6c79f721fa26dd64559c2700086ac852c18e0756) --- updated-dependencies: - dependency-name: ruby/setup-ruby dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 22f27f83ac..4b7ab89f6b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -92,7 +92,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Set up Ruby for asciidoctor-pdf - uses: ruby/setup-ruby@bbda85882f33075a3727c01e3c8d0de0be6146ce # v1 + uses: ruby/setup-ruby@6c79f721fa26dd64559c2700086ac852c18e0756 # v1 with: ruby-version: 3.3.0 - name: Install asciidoctor-pdf / rouge From 406b00b2892878e070cb36f076bb8f6a97c02b5a Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Fri, 14 Mar 2025 12:35:05 +0200 Subject: [PATCH 06/44] Ensure the HTTP/3 stream is closed when an error happens before send operation (#3677) Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .../java/reactor/netty/http/Http3Tests.java | 65 ++++++++++++++++++- .../reactor/netty/http/client/Http3Codec.java | 12 +++- .../http/client/Http3ConnectionProvider.java | 20 +++++- 3 files changed, 93 insertions(+), 4 deletions(-) diff --git a/reactor-netty-http/src/http3Test/java/reactor/netty/http/Http3Tests.java b/reactor-netty-http/src/http3Test/java/reactor/netty/http/Http3Tests.java index 401c4c7da0..2711a0c221 100644 --- a/reactor-netty-http/src/http3Test/java/reactor/netty/http/Http3Tests.java +++ b/reactor-netty-http/src/http3Test/java/reactor/netty/http/Http3Tests.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 VMware, Inc. or its affiliates, All Rights Reserved. + * Copyright (c) 2024-2025 VMware, Inc. or its affiliates, All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -52,6 +52,8 @@ import reactor.netty.NettyPipeline; import reactor.netty.http.client.HttpClient; import reactor.netty.http.client.HttpClientResponse; +import reactor.netty.http.client.HttpConnectionPoolMetrics; +import reactor.netty.http.client.HttpMeterRegistrarAdapter; import reactor.netty.http.server.HttpServer; import reactor.netty.http.server.HttpServerRequest; import reactor.netty.http.server.HttpServerResponse; @@ -891,6 +893,58 @@ private static void doTestTrailerHeaders(HttpClient client, String expectedHeade .verify(Duration.ofSeconds(5)); } + @Test + void clientSendsError() { + TestHttpMeterRegistrarAdapter metricsRegistrar = new TestHttpMeterRegistrarAdapter(); + + ConnectionProvider provider = + ConnectionProvider.builder("clientSendsError") + .maxConnections(1) + .metrics(true, () -> metricsRegistrar) + .build(); + try { + disposableServer = + createServer() + .handle((req, res) -> Mono.empty()) + .bindNow(); + + Mono content = + createClient(provider, disposableServer.port()) + .post() + .uri("/") + .send(Mono.error(new RuntimeException("clientSendsError"))) + .responseContent() + .aggregate() + .asString(); + + List> result = + Flux.range(1, 3) + .flatMapDelayError(i -> content, 256, 32) + .materialize() + .collectList() + .block(Duration.ofSeconds(10)); + + assertThat(result) + .isNotNull() + .hasSize(1) + .allMatch(Signal::hasError); + Throwable error = result.get(0).getThrowable(); + assertThat(error).isNotNull(); + assertThat(error.getSuppressed()) + .isNotNull() + .hasSize(3) + .allMatch(throwable -> "clientSendsError".equals(throwable.getMessage())); + + HttpConnectionPoolMetrics metrics = metricsRegistrar.metrics; + assertThat(metrics).isNotNull(); + assertThat(metrics.activeStreamSize()).isEqualTo(0); + } + finally { + provider.disposeLater() + .block(Duration.ofSeconds(5)); + } + } + static HttpClient createClient(int port) { return createClient(null, port); } @@ -930,4 +984,13 @@ static HttpServer createServer() { private static final String SERVER_RESPONSE_TIME = HTTP_SERVER_PREFIX + RESPONSE_TIME; private static final String SERVER_DATA_SENT_TIME = HTTP_SERVER_PREFIX + DATA_SENT_TIME; private static final String SERVER_DATA_RECEIVED_TIME = HTTP_SERVER_PREFIX + DATA_RECEIVED_TIME; + + static final class TestHttpMeterRegistrarAdapter extends HttpMeterRegistrarAdapter { + HttpConnectionPoolMetrics metrics; + + @Override + protected void registerMetrics(String poolName, String id, SocketAddress remoteAddress, HttpConnectionPoolMetrics metrics) { + this.metrics = metrics; + } + } } diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/Http3Codec.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/Http3Codec.java index 0e935eaaa4..09e0d119e4 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/Http3Codec.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/Http3Codec.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 VMware, Inc. or its affiliates, All Rights Reserved. + * Copyright (c) 2024-2025 VMware, Inc. or its affiliates, All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -35,6 +35,9 @@ import java.util.function.Function; import static reactor.netty.ReactorNetty.format; +import static reactor.netty.http.client.Http2ConnectionProvider.http2PooledRef; +import static reactor.netty.http.client.Http2ConnectionProvider.logStreamsState; +import static reactor.netty.http.client.Http3ConnectionProvider.OWNER; final class Http3Codec extends ChannelInitializer { @@ -107,6 +110,13 @@ else if (metricsRecorder instanceof ContextAwareHttpClientMetricsRecorder) { if (log.isDebugEnabled()) { log.debug(format(ch, "Initialized HTTP/3 stream pipeline {}"), ch.pipeline()); + + ConnectionObserver owner = ch.parent().attr(OWNER).get(); + if (owner instanceof Http3ConnectionProvider.DisposableAcquire) { + Http3ConnectionProvider.DisposableAcquire da = (Http3ConnectionProvider.DisposableAcquire) owner; + Http2Pool.Http2PooledRef http2PooledRef = http2PooledRef(da.pooledRef); + logStreamsState(ch, http2PooledRef.slot, "Stream opened"); + } } } diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/Http3ConnectionProvider.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/Http3ConnectionProvider.java index aad8e2a12b..3422b5f5e5 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/Http3ConnectionProvider.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/Http3ConnectionProvider.java @@ -68,6 +68,8 @@ import static reactor.netty.ReactorNetty.format; import static reactor.netty.ReactorNetty.getChannelContext; import static reactor.netty.ReactorNetty.setChannelContext; +import static reactor.netty.http.client.Http2ConnectionProvider.http2PooledRef; +import static reactor.netty.http.client.Http2ConnectionProvider.logStreamsState; /** * An HTTP/3 implementation for pooled {@link ConnectionProvider}. @@ -181,7 +183,20 @@ static void invalidate(@Nullable ConnectionObserver owner) { } static void registerClose(Channel channel, ConnectionObserver owner) { - channel.closeFuture().addListener(f -> invalidate(owner)); + channel.closeFuture() + .addListener(f -> { + if (owner instanceof DisposableAcquire) { + DisposableAcquire da = (DisposableAcquire) owner; + da.pooledRef + .invalidate() + .subscribe(null, null, () -> { + if (log.isDebugEnabled()) { + Http2Pool.Http2PooledRef http2PooledRef = http2PooledRef(da.pooledRef); + logStreamsState(channel, http2PooledRef.slot, "Stream closed"); + } + }); + } + }); } static final String CONNECTION_PROVIDER_NAME = "http3"; @@ -348,7 +363,8 @@ else if (p.state != null) { QuicStreamChannelBootstrap bootstrap = Http3.newRequestStreamBootstrap((QuicChannel) channel, - new Http3Codec(obs, opsFactory, acceptGzip, loggingHandler, metricsRecorder, remoteAddress, uriTagValue, validate)); + new Http3Codec(obs.then(new HttpClientConfig.StreamConnectionObserver(currentContext())), + opsFactory, acceptGzip, loggingHandler, metricsRecorder, remoteAddress, uriTagValue, validate)); attributes(bootstrap, attributes); channelOptions(bootstrap, options); bootstrap.create().addListener(this); From 769f52c18fe233d65e3d207bf9fb4a3f46d1d4ed Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Fri, 14 Mar 2025 14:13:48 +0200 Subject: [PATCH 07/44] Ensure the connection is invalidated when an error happens before h2c upgrade operation (#3678) Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .../netty/http/client/Http2ConnectionProvider.java | 10 ++++++++++ .../test/java/reactor/netty/http/Http2Tests.java | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2ConnectionProvider.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2ConnectionProvider.java index 1c52347b6d..93b6940fd6 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2ConnectionProvider.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2ConnectionProvider.java @@ -387,6 +387,16 @@ public void onSubscribe(Subscription s) { @Override public void onUncaughtException(Connection connection, Throwable error) { + ConnectionObserver owner = connection.channel().attr(OWNER).get(); + if (owner instanceof DisposableAcquire) { + Http2Pool.Http2PooledRef http2PooledRef = http2PooledRef(((DisposableAcquire) owner).pooledRef); + if (http2PooledRef.slot.h2cUpgradeHandlerCtx() != null && + http2PooledRef.slot.http2MultiplexHandlerCtx() == null) { + // Error happened before H2C upgrade + invalidate(owner); + } + } + obs.onUncaughtException(connection, error); } diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/Http2Tests.java b/reactor-netty-http/src/test/java/reactor/netty/http/Http2Tests.java index d4b25cf2b2..e3f3f2662d 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/Http2Tests.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/Http2Tests.java @@ -689,6 +689,20 @@ private void doTestEmptyDataFrame(HttpServer server, HttpClient client) { .verify(Duration.ofSeconds(10)); } + @ParameterizedTest + @MethodSource("h2cCompatibleCombinations") + void h2cClientSendsError(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols) { + ConnectionProvider provider = ConnectionProvider.create("h2cClientSendsError", 1); + try { + http2ClientSendsError(createServer().protocol(serverProtocols), + createClient(provider, () -> disposableServer.address()).protocol(clientProtocols)); + } + finally { + provider.disposeLater() + .block(Duration.ofSeconds(5)); + } + } + @ParameterizedTest @MethodSource("h2CompatibleCombinations") void h2ClientSendsError(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols) { From 88cd784000dc073f58947ae6e506e71b5425b3d6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 08:54:04 +0200 Subject: [PATCH 08/44] Bump org.junit.platform:junit-platform-launcher from 1.12.0 to 1.12.1 (#3681) Bumps [org.junit.platform:junit-platform-launcher](https://github.com/junit-team/junit5) from 1.12.0 to 1.12.1. - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/commits) --- updated-dependencies: - dependency-name: org.junit.platform:junit-platform-launcher dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index f5da00fdf4..f4f3e7a9b0 100644 --- a/build.gradle +++ b/build.gradle @@ -130,7 +130,7 @@ ext { tomcatVersion = '9.0.102' boringSslVersion = '2.0.70.Final' junitVersion = '5.12.0' - junitPlatformLauncherVersion = '1.12.0' + junitPlatformLauncherVersion = '1.12.1' mockitoVersion = '4.11.0' blockHoundVersion = '1.0.11.RELEASE' reflectionsVersion = '0.10.2' From 687c395a592b1757036d56263dc273ffc4d199cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 09:17:47 +0200 Subject: [PATCH 09/44] Bump ruby/setup-ruby from 1.225.0 to 1.226.0 (#3680) Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.225.0 to 1.226.0. - [Release notes](https://github.com/ruby/setup-ruby/releases) - [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb) - [Commits](https://github.com/ruby/setup-ruby/compare/6c79f721fa26dd64559c2700086ac852c18e0756...922ebc4c5262cd14e07bb0e1db020984b6c064fe) --- updated-dependencies: - dependency-name: ruby/setup-ruby dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4b7ab89f6b..b89d7a7c0b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -92,7 +92,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Set up Ruby for asciidoctor-pdf - uses: ruby/setup-ruby@6c79f721fa26dd64559c2700086ac852c18e0756 # v1 + uses: ruby/setup-ruby@922ebc4c5262cd14e07bb0e1db020984b6c064fe # v1 with: ruby-version: 3.3.0 - name: Install asciidoctor-pdf / rouge From 3eb27d5b649b1e5a9889196ad844c0e881143b6f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 09:59:03 +0200 Subject: [PATCH 10/44] Bump junitVersion from 5.12.0 to 5.12.1 (#3682) Bumps `junitVersion` from 5.12.0 to 5.12.1. Updates `org.junit.jupiter:junit-jupiter-api` from 5.12.0 to 5.12.1 - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/compare/r5.12.0...r5.12.1) Updates `org.junit.jupiter:junit-jupiter-params` from 5.12.0 to 5.12.1 - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/compare/r5.12.0...r5.12.1) Updates `org.junit.jupiter:junit-jupiter-engine` from 5.12.0 to 5.12.1 - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/compare/r5.12.0...r5.12.1) --- updated-dependencies: - dependency-name: org.junit.jupiter:junit-jupiter-api dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.junit.jupiter:junit-jupiter-params dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.junit.jupiter:junit-jupiter-engine dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index f4f3e7a9b0..ec371a0f00 100644 --- a/build.gradle +++ b/build.gradle @@ -129,7 +129,7 @@ ext { hoverflyJavaVersion = '0.19.1' tomcatVersion = '9.0.102' boringSslVersion = '2.0.70.Final' - junitVersion = '5.12.0' + junitVersion = '5.12.1' junitPlatformLauncherVersion = '1.12.1' mockitoVersion = '4.11.0' blockHoundVersion = '1.0.11.RELEASE' From 6dbbc4e21d7903b5c71748a3ca958296974c68ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Mar 2025 14:13:27 +0200 Subject: [PATCH 11/44] Bump com.github.luben:zstd-jni from 1.5.7-1 to 1.5.7-2 (#3684) Bumps [com.github.luben:zstd-jni](https://github.com/luben/zstd-jni) from 1.5.7-1 to 1.5.7-2. - [Commits](https://github.com/luben/zstd-jni/compare/v1.5.7-1...v1.5.7-2) --- updated-dependencies: - dependency-name: com.github.luben:zstd-jni dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 3d36a0eb98..b47b1be8f4 100644 --- a/build.gradle +++ b/build.gradle @@ -123,7 +123,7 @@ ext { // Testing brotli4jVersion = '1.18.0' - zstdJniVersion = '1.5.7-1' + zstdJniVersion = '1.5.7-2' jacksonDatabindVersion = '2.18.3' testAddonVersion = reactorCoreVersion assertJVersion = '3.27.3' From 2726582183e3b4d8379bfe61c128c251a350bb87 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Mar 2025 09:50:37 +0200 Subject: [PATCH 12/44] Bump ruby/setup-ruby from 1.226.0 to 1.227.0 (#3685) Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.226.0 to 1.227.0. - [Release notes](https://github.com/ruby/setup-ruby/releases) - [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb) - [Commits](https://github.com/ruby/setup-ruby/compare/922ebc4c5262cd14e07bb0e1db020984b6c064fe...1a615958ad9d422dd932dc1d5823942ee002799f) --- updated-dependencies: - dependency-name: ruby/setup-ruby dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b89d7a7c0b..52d8ccea62 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -92,7 +92,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Set up Ruby for asciidoctor-pdf - uses: ruby/setup-ruby@922ebc4c5262cd14e07bb0e1db020984b6c064fe # v1 + uses: ruby/setup-ruby@1a615958ad9d422dd932dc1d5823942ee002799f # v1 with: ruby-version: 3.3.0 - name: Install asciidoctor-pdf / rouge From fc986fe1c3745eb8004314ce1e4d00f5cd5090f5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Mar 2025 09:54:01 +0200 Subject: [PATCH 13/44] Bump actions/download-artifact from 4.1.9 to 4.2.0 (#3686) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.1.9 to 4.2.0. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/cc203385981b70ca67e1cc392babf9cc229d5806...b14cf4c92620c250e1c074ab0a5800e37df86765) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 52d8ccea62..9bcf000a4b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -135,7 +135,7 @@ jobs: distribution: 'temurin' java-version: '8' - name: download antora docs/build - uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4 + uses: actions/download-artifact@b14cf4c92620c250e1c074ab0a5800e37df86765 # v4 with: name: ${{ env.DOCS_BUILD_ARTIFACT }} path: docs/build @@ -166,7 +166,7 @@ jobs: distribution: 'temurin' java-version: '8' - name: download antora docs/build - uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4 + uses: actions/download-artifact@b14cf4c92620c250e1c074ab0a5800e37df86765 # v4 with: name: ${{ env.DOCS_BUILD_ARTIFACT }} path: docs/build @@ -199,7 +199,7 @@ jobs: distribution: 'temurin' java-version: '8' - name: download antora docs/build - uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4 + uses: actions/download-artifact@b14cf4c92620c250e1c074ab0a5800e37df86765 # v4 with: name: ${{ env.DOCS_BUILD_ARTIFACT }} path: docs/build From db8ef9ba5e9893b12592b39f1c77a5f4b9fc61b7 Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Wed, 19 Mar 2025 10:31:50 +0200 Subject: [PATCH 14/44] Polish Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .../reactor/netty/tcp/TcpClientTests.java | 124 +++++++++--------- .../netty/http/client/HttpClientTest.java | 63 +++++---- 2 files changed, 91 insertions(+), 96 deletions(-) diff --git a/reactor-netty-core/src/test/java/reactor/netty/tcp/TcpClientTests.java b/reactor-netty-core/src/test/java/reactor/netty/tcp/TcpClientTests.java index 753283768f..a94f7f1b9e 100644 --- a/reactor-netty-core/src/test/java/reactor/netty/tcp/TcpClientTests.java +++ b/reactor-netty-core/src/test/java/reactor/netty/tcp/TcpClientTests.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011-2024 VMware, Inc. or its affiliates, All Rights Reserved. + * Copyright (c) 2011-2025 VMware, Inc. or its affiliates, All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -1394,20 +1394,20 @@ private void doTestSharedNameResolver(TcpClient client, boolean sharedClient) th TcpClient localClient = null; if (sharedClient) { localClient = client.runOn(loop) - .port(disposableServer.port()) - .doOnConnect(config -> resolvers.get().add(config.resolver())) - .doOnConnected(conn -> conn.onDispose(latch::countDown)); + .port(disposableServer.port()) + .doOnConnect(config -> resolvers.get().add(config.resolver())) + .doOnConnected(conn -> conn.onDispose(latch::countDown)); } for (int i = 0; i < count; i++) { if (!sharedClient) { localClient = client.runOn(loop) - .port(disposableServer.port()) - .doOnConnect(config -> resolvers.get().add(config.resolver())) - .doOnConnected(conn -> conn.onDispose(latch::countDown)); + .port(disposableServer.port()) + .doOnConnect(config -> resolvers.get().add(config.resolver())) + .doOnConnected(conn -> conn.onDispose(latch::countDown)); } localClient.handle((in, out) -> in.receive().then()) - .connect() - .subscribe(); + .connect() + .subscribe(); } assertThat(latch.await(30, TimeUnit.SECONDS)).isTrue(); @@ -1471,36 +1471,35 @@ void testTcpClientCancelled() throws InterruptedException { CancelReceiverHandlerTest cancelReceiver = new CancelReceiverHandlerTest(empty::tryEmitEmpty); server = TcpServer.create() - .port(0) - .wiretap(true) - .handle((req, res) -> res.sendString(req.receive() - .asString() - .log("server.receive")) - .then(Mono.never())) - .bindNow(); + .port(0) + .wiretap(true) + .handle((req, res) -> res.sendString(req.receive() + .asString() + .log("server.receive")) + .then(Mono.never())) + .bindNow(); client = TcpClient.create() - .wiretap(true) - .host("localhost") - .port(server.port()) - .doOnConnected(c -> c.addHandlerFirst(cancelReceiver)) - .handle((in, out) -> { - Mono receive = in - .receive() - .asString() - .log("client.receive") - .doOnCancel(cancelled::countDown) - .then(); - - out.sendString(Mono.just("REQUEST")) - .then() - .subscribe(); - - return Flux.zip(receive, empty.asMono()) - .log("zip") - .then(Mono.never()); - }) - .connectNow(); + .wiretap(true) + .host("localhost") + .port(server.port()) + .doOnConnected(c -> c.addHandlerFirst(cancelReceiver)) + .handle((in, out) -> { + Mono receive = in.receive() + .asString() + .log("client.receive") + .doOnCancel(cancelled::countDown) + .then(); + + out.sendString(Mono.just("REQUEST")) + .then() + .subscribe(); + + return Flux.zip(receive, empty.asMono()) + .log("zip") + .then(Mono.never()); + }) + .connectNow(); assertThat(cancelled.await(30, TimeUnit.SECONDS)).as("cancelled await").isTrue(); assertThat(cancelReceiver.awaitAllReleased(30)).as("cancelReceiver").isTrue(); @@ -1532,35 +1531,32 @@ void testTcpClientCancelledByServerClose() throws InterruptedException { CountDownLatch cancelled = new CountDownLatch(1); server = TcpServer.create() - .port(0) - .wiretap(true) - .handle((req, res) -> req.receive() - .asString() - .doOnNext(s -> req.withConnection(DisposableChannel::dispose)) - .then()) - .bindNow(); + .port(0) + .wiretap(true) + .handle((req, res) -> req.receive() + .asString() + .doOnNext(s -> req.withConnection(DisposableChannel::dispose)) + .then()) + .bindNow(); client = TcpClient.create() - .wiretap(true) - .host("localhost") - .port(server.port()) - .handle((in, out) -> { - Mono receive = in - .receive() - .asString() - .log("client.receive") - .doOnCancel(cancelled::countDown) - .then(); - - out.sendString(Mono.just("REQUEST")) - .then() - .subscribe(); - - return receive - .log("receive") - .then(Mono.never()); - }) - .connectNow(); + .wiretap(true) + .host("localhost") + .port(server.port()) + .handle((in, out) -> { + Mono receive = in.receive() + .asString() + .log("client.receive") + .doOnCancel(cancelled::countDown) + .then(); + + out.sendString(Mono.just("REQUEST")) + .then() + .subscribe(); + + return receive.log("receive").then(Mono.never()); + }) + .connectNow(); assertThat(cancelled.await(30, TimeUnit.SECONDS)).as("cancelled await").isTrue(); assertThat(lt.latch.await(30, TimeUnit.SECONDS)).as("logTracker await").isTrue(); diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java index 5b30a8edb3..9109fdff34 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java @@ -166,7 +166,7 @@ static void createSelfSignedCertificate() throws CertificateException { @AfterAll static void cleanup() throws ExecutionException, InterruptedException, TimeoutException { executor.shutdownGracefully() - .get(30, TimeUnit.SECONDS); + .get(30, TimeUnit.SECONDS); } @Test @@ -365,7 +365,7 @@ void serverInfiniteClientClose() throws Exception { disposableServer = createServer() .handle((req, resp) -> { - req.withConnection(cn -> cn.onDispose(latch::countDown)); + req.withConnection(cn -> cn.onDispose(latch::countDown)); return Flux.interval(Duration.ofSeconds(1)) .flatMap(d -> resp.sendObject(Unpooled.EMPTY_BUFFER)); @@ -498,7 +498,7 @@ private void doTestGzip(boolean gzipEnabled) { disposableServer = createServer() .handle((req, res) -> res.sendString(Mono.just(req.requestHeaders() - .get(HttpHeaderNames.ACCEPT_ENCODING, + .get(HttpHeaderNames.ACCEPT_ENCODING, "no gzip")))) .bindNow(); HttpClient client = createHttpClientForContextWithPort(); @@ -653,12 +653,12 @@ void chunkedSendFile() throws URISyntaxException { .host("localhost") .route(r -> r.post("/upload", (req, resp) -> req.receive() - .aggregate() - .asString(StandardCharsets.UTF_8) - .doOnNext(uploaded::set) - .then(resp.status(201) - .sendString(Mono.just("Received File")) - .then()))) + .aggregate() + .asString(StandardCharsets.UTF_8) + .doOnNext(uploaded::set) + .then(resp.status(201) + .sendString(Mono.just("Received File")) + .then()))) .bindNow(); Tuple2 response = @@ -3341,36 +3341,35 @@ void testHttpClientCancelled() throws InterruptedException { Sinks.Empty empty = Sinks.empty(); CancelReceiverHandlerTest cancelReceiver = new CancelReceiverHandlerTest(empty::tryEmitEmpty, 1); - disposableServer = createServer() - .handle((in, out) -> { - in.withConnection(connection -> connection.onDispose(serverClosed::countDown)); - return in.receive() - .asString() - .log("server.receive") - .then(out.sendString(Mono.just("data")).neverComplete()); - }) - .bindNow(); + disposableServer = + createServer().handle((in, out) -> { + in.withConnection(connection -> connection.onDispose(serverClosed::countDown)); + return in.receive() + .asString() + .log("server.receive") + .then(out.sendString(Mono.just("data")).neverComplete()); + }) + .bindNow(); HttpClient httpClient = createHttpClientForContextWithPort(pool); CountDownLatch clientCancelled = new CountDownLatch(1); // Creates a client that should be cancelled by the Flix.zip (see below) - Mono client = httpClient - .doOnRequest((req, conn) -> conn.addHandlerFirst(cancelReceiver)) - .get() - .responseContent() - .aggregate() - .asString() - .log("client") - .doOnCancel(clientCancelled::countDown); + Mono client = + httpClient.doOnRequest((req, conn) -> conn.addHandlerFirst(cancelReceiver)) + .get() + .responseContent() + .aggregate() + .asString() + .log("client") + .doOnCancel(clientCancelled::countDown); // Zip client with a mono which completes with an empty value when the server receives the request. // The client should then be cancelled with a log message. - StepVerifier.create(Flux.zip(client, empty.asMono()) - .log("zip")) - .expectNextCount(0) - .expectComplete() - .verify(Duration.ofSeconds(30)); + StepVerifier.create(Flux.zip(client, empty.asMono()).log("zip")) + .expectNextCount(0) + .expectComplete() + .verify(Duration.ofSeconds(30)); assertThat(cancelReceiver.awaitAllReleased(30)).as("cancelReceiver").isTrue(); assertThat(clientCancelled.await(30, TimeUnit.SECONDS)).as("latchClient await").isTrue(); @@ -3380,7 +3379,7 @@ void testHttpClientCancelled() throws InterruptedException { } finally { pool.disposeLater() - .block(Duration.ofSeconds(30)); + .block(Duration.ofSeconds(30)); } } From 30f5d5534b35a7c11149820c2a43711bd0389ad9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Mar 2025 10:05:49 +0200 Subject: [PATCH 15/44] Bump @springio/asciidoctor-extensions in /docs (#3688) Bumps [@springio/asciidoctor-extensions](https://github.com/spring-io/asciidoctor-extensions) from 1.0.0-alpha.16 to 1.0.0-alpha.17. - [Changelog](https://github.com/spring-io/asciidoctor-extensions/blob/main/CHANGELOG.adoc) - [Commits](https://github.com/spring-io/asciidoctor-extensions/compare/v1.0.0-alpha.16...v1.0.0-alpha.17) --- updated-dependencies: - dependency-name: "@springio/asciidoctor-extensions" dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/package.json b/docs/package.json index ecaa866bac..bf033ec78a 100644 --- a/docs/package.json +++ b/docs/package.json @@ -6,6 +6,6 @@ "@antora/pdf-extension": "1.0.0-alpha.11", "@asciidoctor/tabs": "1.0.0-beta.6", "@springio/antora-extensions": "1.14.4", - "@springio/asciidoctor-extensions": "1.0.0-alpha.16" + "@springio/asciidoctor-extensions": "1.0.0-alpha.17" } } \ No newline at end of file From 204c9ad5dae0144949095df3b1d43bf75f779e83 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Mar 2025 13:10:14 +0200 Subject: [PATCH 16/44] Bump actions/upload-artifact from 4.6.1 to 4.6.2 (#3689) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.1 to 4.6.2. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1...ea165f8d65b6e75b540449e92b4886f43607fa02) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 9bcf000a4b..8bf65eb072 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -108,7 +108,7 @@ jobs: run: |- [ -d docs/build/antora/inject-collector-cache-config-extension/.cache ] && cp -rf docs/build/antora/inject-collector-cache-config-extension/.cache docs/build/site/ - name: Upload docs/build to current workflow run - uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: ${{ env.DOCS_BUILD_ARTIFACT }} retention-days: 3 From c1efb6cfee533ea8ff8912b33e75c44bf5c7fce3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Mar 2025 13:38:19 +0200 Subject: [PATCH 17/44] Bump actions/download-artifact from 4.2.0 to 4.2.1 (#3690) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4.2.0 to 4.2.1. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/b14cf4c92620c250e1c074ab0a5800e37df86765...95815c38cf2ff2164869cbab79da8d1f422bc89e) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8bf65eb072..d69d10ef65 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -135,7 +135,7 @@ jobs: distribution: 'temurin' java-version: '8' - name: download antora docs/build - uses: actions/download-artifact@b14cf4c92620c250e1c074ab0a5800e37df86765 # v4 + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 with: name: ${{ env.DOCS_BUILD_ARTIFACT }} path: docs/build @@ -166,7 +166,7 @@ jobs: distribution: 'temurin' java-version: '8' - name: download antora docs/build - uses: actions/download-artifact@b14cf4c92620c250e1c074ab0a5800e37df86765 # v4 + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 with: name: ${{ env.DOCS_BUILD_ARTIFACT }} path: docs/build @@ -199,7 +199,7 @@ jobs: distribution: 'temurin' java-version: '8' - name: download antora docs/build - uses: actions/download-artifact@b14cf4c92620c250e1c074ab0a5800e37df86765 # v4 + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4 with: name: ${{ env.DOCS_BUILD_ARTIFACT }} path: docs/build From 17c017fc7d4c41a9e16cca53c383797461b43f50 Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Mon, 24 Mar 2025 13:52:48 +0200 Subject: [PATCH 18/44] Ensure HttpInfos#version returns the correct protocol when Unix Domain Sockets (#3693) Fixes #3692 Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .github/workflows/check_transport.yml | 2 +- .../http/client/HttpClientOperations.java | 3 +- .../netty/http/server/HttpServerTests.java | 42 +++++++++++++++---- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/.github/workflows/check_transport.yml b/.github/workflows/check_transport.yml index 33322845cc..999bafead6 100644 --- a/.github/workflows/check_transport.yml +++ b/.github/workflows/check_transport.yml @@ -66,4 +66,4 @@ jobs: run: ./gradlew clean check -x :reactor-netty-core:java17Test --no-daemon -x spotlessCheck -PforceTransport=${{ matrix.transport }} - name: Build and test UDS with NIO on Java 17 if: ${{ ! startsWith(matrix.os, 'windows') }} - run: ./gradlew reactor-netty-http:test --tests reactor.netty.http.server.HttpServerTests.testHttpServerWithDomainSockets_HTTP11 -PtestToolchain=17 --no-daemon -x spotlessCheck -PforceTransport=nio + run: ./gradlew reactor-netty-http:test --tests reactor.netty.http.server.HttpServerTests.testHttpServerWithDomainSockets_HTTP11Post -PtestToolchain=17 --no-daemon -x spotlessCheck -PforceTransport=nio diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientOperations.java index 607f9556e4..75d6f6d528 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientOperations.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientOperations.java @@ -41,6 +41,7 @@ import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.socket.SocketChannel; +import io.netty.channel.unix.DomainSocketChannel; import io.netty.handler.codec.compression.ZlibCodecFactory; import io.netty.handler.codec.http.DefaultFullHttpRequest; import io.netty.handler.codec.http.DefaultHttpRequest; @@ -180,7 +181,7 @@ class HttpClientOperations extends HttpOperations if (c.channel() instanceof Http2StreamChannel) { this.version = H2; } - else if (c.channel() instanceof SocketChannel) { + else if (c.channel() instanceof SocketChannel || c.channel() instanceof DomainSocketChannel) { HttpVersion version = this.nettyRequest.protocolVersion(); if (version.equals(HttpVersion.HTTP_1_0)) { this.version = HttpVersion.HTTP_1_0; diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/server/HttpServerTests.java b/reactor-netty-http/src/test/java/reactor/netty/http/server/HttpServerTests.java index d3ee56ad04..8b265cd8a1 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/server/HttpServerTests.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/server/HttpServerTests.java @@ -168,6 +168,7 @@ import static reactor.netty.http.server.HttpServerFormDecoderProvider.DEFAULT_FORM_DECODER_SPEC; import static reactor.netty.http.server.ConnectionInfo.DEFAULT_HOST_NAME; import static reactor.netty.http.server.ConnectionInfo.DEFAULT_HTTP_PORT; +import static reactor.netty.http.server.HttpTrafficHandler.H2; import static reactor.netty.resources.LoopResources.DEFAULT_SHUTDOWN_TIMEOUT; /** @@ -2015,13 +2016,18 @@ void testHttpServerWithDomainSocketsWithPort() { } @Test - void testHttpServerWithDomainSockets_HTTP11() { - doTestHttpServerWithDomainSockets(HttpServer.create(), HttpClient.create(), "http"); + void testHttpServerWithDomainSockets_HTTP11Get() { + doTestHttpServerWithDomainSockets(HttpServer.create(), HttpClient.create(), HttpMethod.GET, "http", HttpVersion.HTTP_1_1); + } + + @Test + void testHttpServerWithDomainSockets_HTTP11Post() { + doTestHttpServerWithDomainSockets(HttpServer.create(), HttpClient.create(), HttpMethod.POST, "http", HttpVersion.HTTP_1_1); } @Test @SuppressWarnings("deprecation") - void testHttpServerWithDomainSockets_HTTP2() { + void testHttpServerWithDomainSockets_HTTP2Get() { Http2SslContextSpec serverCtx = Http2SslContextSpec.forServer(ssc.certificate(), ssc.privateKey()); Http2SslContextSpec clientCtx = Http2SslContextSpec.forClient() @@ -2029,10 +2035,24 @@ void testHttpServerWithDomainSockets_HTTP2() { doTestHttpServerWithDomainSockets( HttpServer.create().protocol(HttpProtocol.H2).secure(spec -> spec.sslContext(serverCtx)), HttpClient.create().protocol(HttpProtocol.H2).secure(spec -> spec.sslContext(clientCtx)), - "https"); + HttpMethod.GET, "https", H2); } - private void doTestHttpServerWithDomainSockets(HttpServer server, HttpClient client, String expectedScheme) { + @Test + @SuppressWarnings("deprecation") + void testHttpServerWithDomainSockets_HTTP2Post() { + Http2SslContextSpec serverCtx = Http2SslContextSpec.forServer(ssc.certificate(), ssc.privateKey()); + Http2SslContextSpec clientCtx = + Http2SslContextSpec.forClient() + .configure(builder -> builder.trustManager(InsecureTrustManagerFactory.INSTANCE)); + doTestHttpServerWithDomainSockets( + HttpServer.create().protocol(HttpProtocol.H2).secure(spec -> spec.sslContext(serverCtx)), + HttpClient.create().protocol(HttpProtocol.H2).secure(spec -> spec.sslContext(clientCtx)), + HttpMethod.POST, "https", H2); + } + + private void doTestHttpServerWithDomainSockets(HttpServer server, HttpClient client, HttpMethod method, + String expectedScheme, HttpVersion expectedVersion) { boolean isJava17 = System.getProperty("java.version").startsWith("17"); assumeThat(LoopResources.hasNativeSupport() || isJava17).isTrue(); disposableServer = @@ -2045,6 +2065,7 @@ private void doTestHttpServerWithDomainSockets(HttpServer server, HttpClient cli assertThat(req.hostAddress()).isNull(); assertThat(req.remoteAddress()).isNull(); assertThat(req.scheme()).isNotNull().isEqualTo(expectedScheme); + assertThat(req.version()).isEqualTo(expectedVersion); }); assertThat(req.requestHeaders().get(HttpHeaderNames.HOST)).isEqualTo("localhost"); return res.send(req.receive().retain()); @@ -2054,15 +2075,20 @@ private void doTestHttpServerWithDomainSockets(HttpServer server, HttpClient cli String response = client.remoteAddress(disposableServer::address) .wiretap(true) - .post() + .request(method) .uri("/") - .send(ByteBufFlux.fromString(Flux.just("1", "2", "3"))) + .send((req, out) -> HttpMethod.POST.equals(method) ? out.sendString(Flux.just("1", "2", "3")) : Mono.empty()) .responseContent() .aggregate() .asString() .block(Duration.ofSeconds(30)); - assertThat(response).isEqualTo("123"); + if (HttpMethod.POST.equals(method)) { + assertThat(response).isEqualTo("123"); + } + else { + assertThat(response).isNull(); + } } private static SocketAddress createDomainSocketAddress(boolean isJava17) { From 1163ec546834b8bcc58b5613cb1187d7b0edd72e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 26 Mar 2025 09:58:04 +0200 Subject: [PATCH 19/44] Bump gradle/actions from 4.3.0 to 4.3.1 (#3695) Bumps [gradle/actions](https://github.com/gradle/actions) from 4.3.0 to 4.3.1. - [Release notes](https://github.com/gradle/actions/releases) - [Commits](https://github.com/gradle/actions/compare/94baf225fe0a508e581a564467443d0e2379123b...06832c7b30a0129d7fb559bcc6e43d26f6374244) --- updated-dependencies: - dependency-name: gradle/actions dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/check_transport.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check_transport.yml b/.github/workflows/check_transport.yml index 3f135c274a..75586f6bb2 100644 --- a/.github/workflows/check_transport.yml +++ b/.github/workflows/check_transport.yml @@ -51,7 +51,7 @@ jobs: transport: native steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - uses: gradle/actions/wrapper-validation@94baf225fe0a508e581a564467443d0e2379123b + - uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 - name: Set up JDK 1.8 uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 with: From f5723e2e4795787af3b61594e92d66bd1441edf9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 27 Mar 2025 11:10:41 +0200 Subject: [PATCH 20/44] Bump ruby/setup-ruby from 1.227.0 to 1.228.0 (#3696) Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.227.0 to 1.228.0. - [Release notes](https://github.com/ruby/setup-ruby/releases) - [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb) - [Commits](https://github.com/ruby/setup-ruby/compare/1a615958ad9d422dd932dc1d5823942ee002799f...7886c6653556e1164c58a7603d88286b5f708293) --- updated-dependencies: - dependency-name: ruby/setup-ruby dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d69d10ef65..5583aae3a0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -92,7 +92,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Set up Ruby for asciidoctor-pdf - uses: ruby/setup-ruby@1a615958ad9d422dd932dc1d5823942ee002799f # v1 + uses: ruby/setup-ruby@7886c6653556e1164c58a7603d88286b5f708293 # v1 with: ruby-version: 3.3.0 - name: Install asciidoctor-pdf / rouge From 73c01e66a7be40708fc280e84e2273b000e7c6f4 Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Thu, 27 Mar 2025 11:17:33 +0200 Subject: [PATCH 21/44] Add API for determining the resolved addresses to which this client should connect (#3687) Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .../netty/transport/ClientTransport.java | 42 ++++++++++++ .../transport/ClientTransportConfig.java | 9 +++ .../netty/transport/TransportConnector.java | 33 ++++++++-- .../reactor/netty/tcp/TcpClientTests.java | 65 +++++++++++++++++++ .../netty/http/client/HttpClientTest.java | 58 +++++++++++++++++ 5 files changed, 200 insertions(+), 7 deletions(-) diff --git a/reactor-netty-core/src/main/java/reactor/netty/transport/ClientTransport.java b/reactor-netty-core/src/main/java/reactor/netty/transport/ClientTransport.java index 5d9a312c99..7846dc8148 100644 --- a/reactor-netty-core/src/main/java/reactor/netty/transport/ClientTransport.java +++ b/reactor-netty-core/src/main/java/reactor/netty/transport/ClientTransport.java @@ -16,10 +16,13 @@ package reactor.netty.transport; import java.net.SocketAddress; +import java.net.UnknownHostException; import java.time.Duration; +import java.util.List; import java.util.Objects; import java.util.Properties; import java.util.function.BiConsumer; +import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Supplier; @@ -29,6 +32,7 @@ import reactor.netty.Connection; import reactor.netty.ConnectionObserver; import reactor.netty.resources.LoopResources; +import reactor.util.annotation.Nullable; /** * A generic client {@link Transport} that will {@link #connect()} to a remote address and provide a {@link Connection}. @@ -43,6 +47,29 @@ public abstract class ClientTransport, CONF extends ClientTransportConfig> extends Transport { + /** + * An interface for selecting resolved addresses based on configuration and available socket addresses. + * + * @param client configuration implementation + * @since 1.2.5 + */ + public interface ResolvedAddressSelector + extends BiFunction, List> { + + /** + * Selects the resolved addresses to be used for a connection. + * If empty list is returned or {@code null}, the connection establishment will fail with + * {@link UnknownHostException} + * + * @param config client configuration implementation + * @param resolvedAddresses the list of resolved socket addresses + * @return the selected list of socket addresses + */ + @Override + @Nullable + List apply(CONF config, List resolvedAddresses); + } + /** * Connect the {@link ClientTransport} and return a {@link Mono} of {@link Connection}. If * {@link Mono} is cancelled, the underlying connection will be aborted. Once the @@ -330,6 +357,21 @@ public T remoteAddress(Supplier remoteAddressSupplier) return dup; } + /** + * Determines the resolved addresses to which this client should connect for each subscription. + * + * @param resolvedAddressesSelector a {@link ResolvedAddressSelector} invoked after resolving + * the remote address to determine which addresses should be used for the connection. + * @return a new {@link ClientTransport} + * @since 1.2.5 + */ + public T resolvedAddressesSelector(ResolvedAddressSelector resolvedAddressesSelector) { + Objects.requireNonNull(resolvedAddressesSelector, "resolvedAddressesSelector"); + T dup = duplicate(); + dup.configuration().resolvedAddressesSelector = resolvedAddressesSelector; + return dup; + } + /** * Assign an {@link AddressResolverGroup}. * diff --git a/reactor-netty-core/src/main/java/reactor/netty/transport/ClientTransportConfig.java b/reactor-netty-core/src/main/java/reactor/netty/transport/ClientTransportConfig.java index f13d5f1258..f7a8f6df9e 100644 --- a/reactor-netty-core/src/main/java/reactor/netty/transport/ClientTransportConfig.java +++ b/reactor-netty-core/src/main/java/reactor/netty/transport/ClientTransportConfig.java @@ -16,6 +16,7 @@ package reactor.netty.transport; import java.net.SocketAddress; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.ConcurrentHashMap; @@ -172,6 +173,7 @@ public final AddressResolverGroup resolver() { ProxyProvider proxyProvider; Supplier proxyProviderSupplier; Supplier remoteAddress; + ClientTransport.ResolvedAddressSelector resolvedAddressesSelector; AddressResolverGroup resolver; protected ClientTransportConfig(ConnectionProvider connectionProvider, Map, ?> options, @@ -190,6 +192,7 @@ protected ClientTransportConfig(ClientTransportConfig parent) { this.doOnResolve = parent.doOnResolve; this.doAfterResolve = parent.doAfterResolve; this.doOnResolveError = parent.doOnResolveError; + this.resolvedAddressesSelector = parent.resolvedAddressesSelector; this.nameResolverProvider = parent.nameResolverProvider; this.proxyProvider = parent.proxyProvider; this.proxyProviderSupplier = parent.proxyProviderSupplier; @@ -255,6 +258,12 @@ protected AddressResolverGroup resolverInternal() { } } + @Nullable + @SuppressWarnings("unchecked") + final List applyResolvedAddressesSelector(List resolvedAddresses) { + return resolvedAddressesSelector != null ? resolvedAddressesSelector.apply((CONF) this, resolvedAddresses) : resolvedAddresses; + } + static final ConcurrentMap RESOLVERS_CACHE = new ConcurrentHashMap<>(); static DnsAddressResolverGroup getOrCreateResolver( diff --git a/reactor-netty-core/src/main/java/reactor/netty/transport/TransportConnector.java b/reactor-netty-core/src/main/java/reactor/netty/transport/TransportConnector.java index 48d834f1c2..d1dffc7ea6 100644 --- a/reactor-netty-core/src/main/java/reactor/netty/transport/TransportConnector.java +++ b/reactor-netty-core/src/main/java/reactor/netty/transport/TransportConnector.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2024 VMware, Inc. or its affiliates, All Rights Reserved. + * Copyright (c) 2020-2025 VMware, Inc. or its affiliates, All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,6 +41,7 @@ import reactor.util.retry.Retry; import java.net.SocketAddress; +import java.net.UnknownHostException; import java.util.Collections; import java.util.List; import java.util.Map; @@ -222,7 +223,7 @@ static void setChannelOptions(Channel channel, Map, ?> options, } static void doConnect( - List addresses, + List addresses, @Nullable Supplier bindAddress, MonoChannelPromise connectPromise, int index) { @@ -315,7 +316,7 @@ static Mono doResolveAndConnect(Channel channel, TransportConfig config Supplier bindAddress = config.bindAddress(); if (!resolver.isSupported(remoteAddress) || resolver.isResolved(remoteAddress)) { MonoChannelPromise monoChannelPromise = new MonoChannelPromise(channel); - doConnect(Collections.singletonList(remoteAddress), bindAddress, monoChannelPromise, 0); + doConnect(selectedAddresses(config, remoteAddress, Collections.singletonList(remoteAddress)), bindAddress, monoChannelPromise, 0); return monoChannelPromise; } @@ -364,7 +365,7 @@ static Mono doResolveAndConnect(Channel channel, TransportConfig config } else { MonoChannelPromise monoChannelPromise = new MonoChannelPromise(channel); - doConnect(resolveFuture.getNow(), bindAddress, monoChannelPromise, 0); + doConnect(selectedAddresses(config, remoteAddress, resolveFuture.getNow()), bindAddress, monoChannelPromise, 0); return monoChannelPromise; } } @@ -375,7 +376,7 @@ static Mono doResolveAndConnect(Channel channel, TransportConfig config monoChannelPromise.tryFailure(future.cause()); } else { - doConnect(future.getNow(), bindAddress, monoChannelPromise, 0); + doConnect(selectedAddresses(config, remoteAddress, future.getNow()), bindAddress, monoChannelPromise, 0); } }); return monoChannelPromise; @@ -385,6 +386,24 @@ static Mono doResolveAndConnect(Channel channel, TransportConfig config } } + static List selectedAddresses(TransportConfig config, SocketAddress remoteAddress, + List resolvedAddresses) throws UnknownHostException { + List selectedAddresses = resolvedAddresses; + if (config instanceof ClientTransportConfig) { + ClientTransportConfig clientTransportConfig = (ClientTransportConfig) config; + if (clientTransportConfig.resolvedAddressesSelector != null) { + selectedAddresses = clientTransportConfig.applyResolvedAddressesSelector(resolvedAddresses); + if (selectedAddresses == null || selectedAddresses.isEmpty()) { + if (log.isDebugEnabled()) { + log.debug("No address was chosen by the configured selector for resolved addresses {}", resolvedAddresses); + } + throw new UnknownHostException("Failed to resolve [" + remoteAddress + "]"); + } + } + } + return selectedAddresses; + } + static final class MonoChannelPromise extends Mono implements ChannelPromise, Subscription { final Channel channel; @@ -652,9 +671,9 @@ void _subscribe(CoreSubscriber actual) { static final class RetryConnectException extends RuntimeException { - final List addresses; + final List addresses; - RetryConnectException(List addresses) { + RetryConnectException(List addresses) { this.addresses = addresses; } diff --git a/reactor-netty-core/src/test/java/reactor/netty/tcp/TcpClientTests.java b/reactor-netty-core/src/test/java/reactor/netty/tcp/TcpClientTests.java index a94f7f1b9e..ded3fbcd83 100644 --- a/reactor-netty-core/src/test/java/reactor/netty/tcp/TcpClientTests.java +++ b/reactor-netty-core/src/test/java/reactor/netty/tcp/TcpClientTests.java @@ -26,6 +26,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Properties; import java.util.Set; @@ -40,6 +41,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Supplier; +import java.util.stream.Collectors; import io.netty.bootstrap.Bootstrap; import io.netty.buffer.ByteBuf; @@ -74,6 +76,7 @@ import reactor.netty.channel.ChannelOperations; import reactor.netty.resources.ConnectionProvider; import reactor.netty.resources.LoopResources; +import reactor.netty.transport.ClientTransport; import reactor.netty.transport.NameResolverProvider; import reactor.test.StepVerifier; import reactor.util.Logger; @@ -1570,4 +1573,66 @@ void testTcpClientCancelledByServerClose() throws InterruptedException { } } } + + @Test + void testSelectedIpsEmpty() { + doTestSelectedIps((tcpClientConfig, list) -> Collections.emptyList(), true); + } + + @Test + void testSelectedIpsNull() { + doTestSelectedIps((tcpClientConfig, list) -> null, true); + } + + @Test + void testSelectedIpsFilter() { + doTestSelectedIps((tcpClientConfig, list) -> list.stream().filter(o -> false).collect(Collectors.toList()), true); + } + + @Test + void testSelectedIpsCheckConfig() { + doTestSelectedIps((tcpClientConfig, list) -> tcpClientConfig.hasProxy() ? list : null, true); + } + + @Test + void testSelectedIps() { + doTestSelectedIps((tcpClientConfig, list) -> list, false); + } + + private static void doTestSelectedIps( + ClientTransport.ResolvedAddressSelector resolvedIpFilter, + boolean expectError) { + DisposableServer disposableServer = null; + try { + disposableServer = + TcpServer.create() + .handle((in, out) -> out.sendString(Mono.just("testSelectedIps"))) + .bindNow(); + + SocketAddress address = disposableServer.address(); + Flux result = + TcpClient.create() + .resolvedAddressesSelector(resolvedIpFilter) + .remoteAddress(() -> address) + .connect() + .flatMapMany(c -> c.inbound().receive().asString()); + + if (expectError) { + result.as(StepVerifier::create) + .expectErrorMatches(t -> ("Failed to resolve [" + address + "]").equals(t.getMessage())) + .verify(Duration.ofSeconds(5)); + } + else { + result.as(StepVerifier::create) + .expectNextMatches(s -> s.startsWith("testSelectedIps")) + .expectComplete() + .verify(Duration.ofSeconds(5)); + } + } + finally { + if (disposableServer != null) { + disposableServer.disposeNow(); + } + } + } } diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java index 41df33b4dd..692348b40c 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java @@ -35,6 +35,7 @@ import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Set; @@ -56,6 +57,7 @@ import java.util.function.BiFunction; import java.util.function.Consumer; import java.util.function.Function; +import java.util.stream.Collectors; import javax.net.ssl.SSLException; import io.netty.buffer.ByteBuf; @@ -135,6 +137,7 @@ import reactor.netty.tcp.SslProvider; import reactor.netty.tcp.TcpClient; import reactor.netty.tcp.TcpServer; +import reactor.netty.transport.ClientTransport; import reactor.netty.transport.TransportConfig; import reactor.test.StepVerifier; import reactor.util.Logger; @@ -3740,6 +3743,61 @@ void testDeleteMethod(String requestBodyType) { .verify(Duration.ofSeconds(5)); } + @Test + void testSelectedIpsEmpty() { + doTestSelectedIps((httpClientConfig, list) -> Collections.emptyList(), true); + } + + @Test + void testSelectedIpsNull() { + doTestSelectedIps((httpClientConfig, list) -> null, true); + } + + @Test + void testSelectedIpsFilter() { + doTestSelectedIps((httpClientConfig, list) -> list.stream().filter(o -> false).collect(Collectors.toList()), true); + } + + @Test + void testSelectedIpsCheckConfig() { + doTestSelectedIps((httpClientConfig, list) -> "".equals(httpClientConfig.uri()) ? list : null, true); + } + + @Test + void testSelectedIps() { + doTestSelectedIps((httpClientConfig, list) -> list, false); + } + + private void doTestSelectedIps( + ClientTransport.ResolvedAddressSelector resolvedIpFilter, + boolean expectError) { + disposableServer = + createServer() + .handle((in, out) -> out.sendString(Mono.just("testSelectedIps"))) + .bindNow(); + + SocketAddress address = disposableServer.address(); + Flux result = + createClient(() -> address) + .resolvedAddressesSelector(resolvedIpFilter) + .get() + .uri("/") + .responseContent() + .asString(); + + if (expectError) { + result.as(StepVerifier::create) + .expectErrorMatches(t -> ("Failed to resolve [" + address + "]").equals(t.getMessage())) + .verify(Duration.ofSeconds(5)); + } + else { + result.as(StepVerifier::create) + .expectNext("testSelectedIps") + .expectComplete() + .verify(Duration.ofSeconds(5)); + } + } + private static final class EchoAction implements Publisher, Consumer { private final Publisher sender; private volatile FluxSink emitter; From cd3283c02aa797ad968c1554985a5923f00ca319 Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Thu, 27 Mar 2025 15:02:02 +0200 Subject: [PATCH 22/44] Support websocket over HTTP/2 (#3691) Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- reactor-netty-http/build.gradle | 1 + .../reactor/netty/http/Http2SettingsSpec.java | 38 +- .../Http2WebsocketClientOperations.java | 635 +++++++ .../netty/http/client/HttpClientConnect.java | 18 + .../http/client/HttpClientOperations.java | 41 +- .../netty/http/client/HttpTrafficHandler.java | 5 +- .../client/WebsocketClientOperations.java | 77 +- .../Http2WebsocketServerOperations.java | 600 ++++++ .../netty/http/server/HttpServerConfig.java | 53 +- .../http/server/HttpServerOperations.java | 26 +- .../netty/http/server/HttpServerRoutes.java | 10 +- .../server/WebsocketServerOperations.java | 31 +- .../reactor-netty-http/reflect-config.json | 42 + .../netty/http/Http2SettingsSpecTests.java | 24 +- .../http/client/Http11WebsocketTest.java | 427 +++++ .../netty/http/client/Http2WebsocketTest.java | 667 +++++++ .../netty/http/client/WebsocketTest.java | 1634 +++++++---------- 17 files changed, 3265 insertions(+), 1064 deletions(-) create mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/client/Http2WebsocketClientOperations.java create mode 100644 reactor-netty-http/src/main/java/reactor/netty/http/server/Http2WebsocketServerOperations.java create mode 100644 reactor-netty-http/src/test/java/reactor/netty/http/client/Http11WebsocketTest.java create mode 100644 reactor-netty-http/src/test/java/reactor/netty/http/client/Http2WebsocketTest.java diff --git a/reactor-netty-http/build.gradle b/reactor-netty-http/build.gradle index 2535a7576f..31b4e26bb0 100644 --- a/reactor-netty-http/build.gradle +++ b/reactor-netty-http/build.gradle @@ -283,6 +283,7 @@ task japicmp(type: JapicmpTask) { compatibilityChangeExcludes = [ "METHOD_NEW_DEFAULT" ] methodExcludes = [ + 'reactor.netty.http.Http2SettingsSpec$Builder#connectProtocolEnabled(boolean)' ] } diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/Http2SettingsSpec.java b/reactor-netty-http/src/main/java/reactor/netty/http/Http2SettingsSpec.java index 40d15d44ae..012b34a0b4 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/Http2SettingsSpec.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/Http2SettingsSpec.java @@ -38,6 +38,15 @@ public interface Builder { */ Http2SettingsSpec build(); + /** + * Sets the {@code SETTINGS_ENABLE_CONNECT_PROTOCOL} value. + * + * @param connectProtocolEnabled the {@code SETTINGS_ENABLE_CONNECT_PROTOCOL} value + * @return {@code this} + * @since 1.2.5 + */ + Builder connectProtocolEnabled(boolean connectProtocolEnabled); + /** * Sets the {@code SETTINGS_HEADER_TABLE_SIZE} value. * @@ -104,6 +113,17 @@ public static Builder builder() { return new Build(); } + /** + * Returns the configured {@code SETTINGS_ENABLE_CONNECT_PROTOCOL} value or null. + * + * @return the configured {@code SETTINGS_ENABLE_CONNECT_PROTOCOL} value or null + * @since 1.2.5 + */ + @Nullable + public Boolean connectProtocolEnabled() { + return connectProtocolEnabled; + } + /** * Returns the configured {@code SETTINGS_HEADER_TABLE_SIZE} value or null. * @@ -185,7 +205,8 @@ public boolean equals(Object o) { return false; } Http2SettingsSpec that = (Http2SettingsSpec) o; - return Objects.equals(headerTableSize, that.headerTableSize) && + return Objects.equals(connectProtocolEnabled, that.connectProtocolEnabled) && + Objects.equals(headerTableSize, that.headerTableSize) && Objects.equals(initialWindowSize, that.initialWindowSize) && Objects.equals(maxConcurrentStreams, that.maxConcurrentStreams) && Objects.equals(maxFrameSize, that.maxFrameSize) && @@ -197,6 +218,7 @@ public boolean equals(Object o) { @Override public int hashCode() { int result = 1; + result = 31 * result + (connectProtocolEnabled == null ? 0 : Boolean.hashCode(connectProtocolEnabled)); result = 31 * result + (headerTableSize == null ? 0 : Long.hashCode(headerTableSize)); result = 31 * result + (initialWindowSize == null ? 0 : initialWindowSize); result = 31 * result + (maxConcurrentStreams == null ? 0 : Long.hashCode(maxConcurrentStreams)); @@ -207,6 +229,12 @@ public int hashCode() { return result; } + // https://datatracker.ietf.org/doc/html/rfc8441#section-9.1 + public static final char SETTINGS_ENABLE_CONNECT_PROTOCOL = 8; + public static final Long FALSE = 0L; + public static final Long TRUE = 1L; + + final Boolean connectProtocolEnabled; final Long headerTableSize; final Integer initialWindowSize; final Long maxConcurrentStreams; @@ -217,6 +245,7 @@ public int hashCode() { Http2SettingsSpec(Build build) { Http2Settings settings = build.http2Settings; + connectProtocolEnabled = build.connectProtocolEnabled; headerTableSize = settings.headerTableSize(); initialWindowSize = settings.initialWindowSize(); if (settings.maxConcurrentStreams() != null) { @@ -233,6 +262,7 @@ public int hashCode() { } static final class Build implements Builder { + Boolean connectProtocolEnabled; Long maxStreams; final Http2Settings http2Settings = Http2Settings.defaultSettings(); @@ -241,6 +271,12 @@ public Http2SettingsSpec build() { return new Http2SettingsSpec(this); } + @Override + public Builder connectProtocolEnabled(boolean connectProtocolEnabled) { + this.connectProtocolEnabled = Boolean.valueOf(connectProtocolEnabled); + return this; + } + @Override public Builder headerTableSize(long headerTableSize) { http2Settings.headerTableSize(headerTableSize); diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2WebsocketClientOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2WebsocketClientOperations.java new file mode 100644 index 0000000000..26de5d2ac2 --- /dev/null +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/Http2WebsocketClientOperations.java @@ -0,0 +1,635 @@ +/* + * Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved. + * + * 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 + * + * https://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 reactor.netty.http.client; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelDuplexHandler; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelOutboundHandlerAdapter; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.ChannelPromise; +import io.netty.handler.codec.CodecException; +import io.netty.handler.codec.compression.ZlibCodecFactory; +import io.netty.handler.codec.http.DefaultHttpContent; +import io.netty.handler.codec.http.DefaultHttpRequest; +import io.netty.handler.codec.http.DefaultHttpResponse; +import io.netty.handler.codec.http.FullHttpResponse; +import io.netty.handler.codec.http.HttpContent; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpHeaderValues; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpScheme; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.LastHttpContent; +import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame; +import io.netty.handler.codec.http.websocketx.WebSocket13FrameDecoder; +import io.netty.handler.codec.http.websocketx.WebSocket13FrameEncoder; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakeException; +import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus; +import io.netty.handler.codec.http.websocketx.WebSocketScheme; +import io.netty.handler.codec.http.websocketx.extensions.WebSocketClientExtension; +import io.netty.handler.codec.http.websocketx.extensions.WebSocketClientExtensionHandshaker; +import io.netty.handler.codec.http.websocketx.extensions.WebSocketExtensionData; +import io.netty.handler.codec.http.websocketx.extensions.WebSocketExtensionDecoder; +import io.netty.handler.codec.http.websocketx.extensions.WebSocketExtensionEncoder; +import io.netty.handler.codec.http.websocketx.extensions.WebSocketExtensionUtil; +import io.netty.handler.codec.http.websocketx.extensions.compression.DeflateFrameClientExtensionHandshaker; +import io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateClientExtensionHandshaker; +import io.netty.handler.codec.http2.Http2Headers; +import io.netty.handler.codec.http2.Http2HeadersFrame; +import io.netty.util.AsciiString; +import io.netty.util.NetUtil; +import reactor.netty.NettyPipeline; +import reactor.util.annotation.Nullable; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static io.netty.handler.codec.http.LastHttpContent.EMPTY_LAST_CONTENT; +import static io.netty.handler.codec.http.websocketx.WebSocketVersion.V13; +import static io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateServerExtensionHandshaker.MAX_WINDOW_SIZE; +import static reactor.netty.NettyPipeline.LEFT; + +final class Http2WebsocketClientOperations extends WebsocketClientOperations { + + WebsocketClientHandshaker handshakerHttp2; + + Http2WebsocketClientOperations(URI currentURI, WebsocketClientSpec websocketClientSpec, HttpClientOperations replaced) { + super(currentURI, websocketClientSpec, replaced); + } + + @Override + @SuppressWarnings("FutureReturnValueIgnored") + public void onInboundNext(ChannelHandlerContext ctx, Object msg) { + if (msg instanceof FullHttpResponse) { + FullHttpResponse response = (FullHttpResponse) msg; + HttpResponseStatus status = response.status(); + response.content().release(); + String errorMsg = !HttpResponseStatus.OK.equals(status) ? + "Invalid websocket handshake response status [" + status + "]." : + "Failed to upgrade to websocket. End of stream is received."; + onInboundError(new WebSocketClientHandshakeException(errorMsg, response)); + //"FutureReturnValueIgnored" this is deliberate + ctx.close(); + } + else if (msg instanceof HttpResponse) { + started = true; + + HttpResponse response = (HttpResponse) msg; + + setNettyResponse(response); + + if (notRedirected(response)) { + try { + HttpResponseStatus status = response.status(); + if (!HttpResponseStatus.OK.equals(status)) { + throw new WebSocketClientHandshakeException( + "Invalid websocket handshake response status [" + status + "].", response); + } + + handshakerHttp2.finishHandshake(channel(), response); + // This change is needed after the Netty change https://github.com/netty/netty/pull/11966 + ctx.read(); + listener().onStateChange(this, HttpClientState.RESPONSE_RECEIVED); + } + catch (Exception e) { + onInboundError(e); + //"FutureReturnValueIgnored" this is deliberate + ctx.close(); + } + } + else { + listener().onUncaughtException(this, redirecting); + } + } + else { + super.onInboundNext(ctx, msg); + } + } + + @Override + @Nullable + public String selectedSubprotocol() { + return handshakerHttp2.actualSubProtocol; + } + + @Override + void initHandshaker(URI currentURI, WebsocketClientSpec websocketClientSpec) { + if (websocketClientSpec.version() != V13) { + throw new WebSocketClientHandshakeException( + "Websocket version [" + websocketClientSpec.version().toHttpHeaderValue() + "] is not supported."); + } + + removeHandler(NettyPipeline.HttpMetricsHandler); + + if (websocketClientSpec.compress()) { + requestHeaders().remove(HttpHeaderNames.ACCEPT_ENCODING); + // Returned value is deliberately ignored + removeHandler(NettyPipeline.HttpDecompressor); + // Returned value is deliberately ignored + PerMessageDeflateClientExtensionHandshaker perMessageDeflateClientExtensionHandshaker = + new PerMessageDeflateClientExtensionHandshaker(6, ZlibCodecFactory.isSupportingWindowSizeAndMemLevel(), + MAX_WINDOW_SIZE, websocketClientSpec.compressionAllowClientNoContext(), + websocketClientSpec.compressionRequestedServerNoContext()); + addHandlerFirst(NettyPipeline.WsCompressionHandler, + new WebsocketClientExtensionHandler(Arrays.asList( + perMessageDeflateClientExtensionHandshaker, + new DeflateFrameClientExtensionHandshaker(false), + new DeflateFrameClientExtensionHandshaker(true)))); + } + + String subProtocols = websocketClientSpec.protocols(); + handshakerHttp2 = new WebsocketClientHandshaker( + currentURI, + subProtocols != null && !subProtocols.isEmpty() ? subProtocols : null, + requestHeaders().remove(HttpHeaderNames.HOST), + websocketClientSpec.maxFramePayloadLength()); + + Channel channel = channel(); + handshakerHttp2.handshake(channel) + .addListener(f -> { + markPersistent(false); + channel.read(); + }); + } + + @Override + boolean isHandshakeComplete() { + return handshakerHttp2.handshakeComplete; + } + + @Override + @SuppressWarnings("FutureReturnValueIgnored") + void sendCloseNow(CloseWebSocketFrame frame, WebSocketCloseStatus closeStatus) { + if (!frame.isFinalFragment()) { + //"FutureReturnValueIgnored" this is deliberate + channel().writeAndFlush(frame); + return; + } + if (CLOSE_SENT.getAndSet(this, 1) == 0) { + // EmitResult is ignored as CLOSE_SENT guarantees that there will be only one emission + // Whether there are subscribers or the subscriber cancels is not of interest + // Evaluated EmitResult: FAIL_TERMINATED, FAIL_OVERFLOW, FAIL_CANCELLED, FAIL_NON_SERIALIZED + // FAIL_ZERO_SUBSCRIBER + onCloseState.tryEmitValue(closeStatus); + //"FutureReturnValueIgnored" this is deliberate + channel().write(frame); + channel().writeAndFlush(EMPTY_LAST_CONTENT); + } + else { + frame.release(); + } + } + + /* This class is based on io.netty.handler.codec.http.websocketx.extensions.WebSocketClientExtensionHandler but adapted for HTTP/2 */ + static final class WebsocketClientExtensionHandler extends ChannelDuplexHandler { + + final List extensionHandshakers; + + WebsocketClientExtensionHandler(List extensionHandshakers) { + this.extensionHandshakers = extensionHandshakers; + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + if (msg != LastHttpContent.EMPTY_LAST_CONTENT) { + if (msg.getClass() == DefaultHttpResponse.class) { + onHttpResponseChannelRead(ctx, (DefaultHttpResponse) msg); + } + else if (msg instanceof HttpResponse && !(msg instanceof FullHttpResponse)) { + onHttpResponseChannelRead(ctx, (HttpResponse) msg); + } + else { + ctx.fireChannelRead(msg); + } + } + else { + ctx.fireChannelRead(msg); + } + } + + @Override + @SuppressWarnings("FutureReturnValueIgnored") + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { + if (msg != Unpooled.EMPTY_BUFFER && !(msg instanceof ByteBuf)) { + if (msg.getClass() == DefaultHttpRequest.class) { + onHttpRequestWrite(ctx, (DefaultHttpRequest) msg, promise); + } + else if (msg instanceof HttpRequest) { + onHttpRequestWrite(ctx, (HttpRequest) msg, promise); + } + else { + //"FutureReturnValueIgnored" this is deliberate + ctx.write(msg, promise); + } + } + else { + //"FutureReturnValueIgnored" this is deliberate + ctx.write(msg, promise); + } + } + + @SuppressWarnings("FutureReturnValueIgnored") + void onHttpRequestWrite(ChannelHandlerContext ctx, HttpRequest request, ChannelPromise promise) { + String headerValue = request.headers().getAsString(HttpHeaderNames.SEC_WEBSOCKET_EXTENSIONS); + List extraExtensions = new ArrayList<>(extensionHandshakers.size()); + for (WebSocketClientExtensionHandshaker extensionHandshaker : extensionHandshakers) { + extraExtensions.add(extensionHandshaker.newRequestData()); + } + String newHeaderValue = computeMergeExtensionsHeaderValue(headerValue, extraExtensions); + + request.headers().set(HttpHeaderNames.SEC_WEBSOCKET_EXTENSIONS, newHeaderValue); + + //"FutureReturnValueIgnored" this is deliberate + ctx.write(request, promise); + } + + void onHttpResponseChannelRead(ChannelHandlerContext ctx, HttpResponse response) { + if (HttpResponseStatus.OK.equals(response.status())) { + String extensionsHeader = response.headers().getAsString(HttpHeaderNames.SEC_WEBSOCKET_EXTENSIONS); + + if (extensionsHeader != null) { + List extensions = WebSocketExtensionUtil.extractExtensions(extensionsHeader); + List validExtensions = new ArrayList<>(extensions.size()); + int rsv = 0; + + for (WebSocketExtensionData extensionData : extensions) { + Iterator extensionHandshakersIterator = + extensionHandshakers.iterator(); + WebSocketClientExtension validExtension = null; + + while (validExtension == null && extensionHandshakersIterator.hasNext()) { + WebSocketClientExtensionHandshaker extensionHandshaker = + extensionHandshakersIterator.next(); + validExtension = extensionHandshaker.handshakeExtension(extensionData); + } + + if (validExtension != null && ((validExtension.rsv() & rsv) == 0)) { + rsv = rsv | validExtension.rsv(); + validExtensions.add(validExtension); + } + else { + throw new CodecException("invalid Websocket Extension handshake for [" + extensionsHeader + ']'); + } + } + + for (WebSocketClientExtension validExtension : validExtensions) { + WebSocketExtensionDecoder decoder = validExtension.newExtensionDecoder(); + WebSocketExtensionEncoder encoder = validExtension.newExtensionEncoder(); + ctx.pipeline().addAfter(ctx.name(), decoder.getClass().getName(), decoder); + ctx.pipeline().addAfter(ctx.name(), encoder.getClass().getName(), encoder); + } + } + + ctx.pipeline().remove(ctx.name()); + } + + ctx.fireChannelRead(response); + } + + static final String EXTENSION_SEPARATOR = ","; + static final String PARAMETER_SEPARATOR = ";"; + static final char PARAMETER_EQUAL = '='; + + static String computeMergeExtensionsHeaderValue(@Nullable String userDefinedHeaderValue, List extraExtensions) { + List userDefinedExtensions = + userDefinedHeaderValue != null ? WebSocketExtensionUtil.extractExtensions(userDefinedHeaderValue) : Collections.emptyList(); + + for (WebSocketExtensionData userDefined : userDefinedExtensions) { + WebSocketExtensionData matchingExtra = null; + int i; + for (i = 0; i < extraExtensions.size(); i++) { + WebSocketExtensionData extra = extraExtensions.get(i); + if (extra.name().equals(userDefined.name())) { + matchingExtra = extra; + break; + } + } + if (matchingExtra == null) { + extraExtensions.add(userDefined); + } + else { + // merge with higher precedence to user defined parameters + Map mergedParameters = new HashMap<>(matchingExtra.parameters()); + mergedParameters.putAll(userDefined.parameters()); + extraExtensions.set(i, new WebSocketExtensionData(matchingExtra.name(), mergedParameters)); + } + } + + StringBuilder sb = new StringBuilder(150); + + for (WebSocketExtensionData data : extraExtensions) { + sb.append(data.name()); + for (Map.Entry parameter : data.parameters().entrySet()) { + sb.append(PARAMETER_SEPARATOR); + sb.append(parameter.getKey()); + if (parameter.getValue() != null) { + sb.append(PARAMETER_EQUAL); + sb.append(parameter.getValue()); + } + } + sb.append(EXTENSION_SEPARATOR); + } + + if (!extraExtensions.isEmpty()) { + sb.setLength(sb.length() - EXTENSION_SEPARATOR.length()); + } + + return sb.toString(); + } + } + + /* This class is based on io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker but adapted for HTTP/2 */ + static final class WebsocketClientHandshaker { + + static final String HTTP_SCHEME_PREFIX = HttpScheme.HTTP + "://"; + static final String HTTPS_SCHEME_PREFIX = HttpScheme.HTTPS + "://"; + static final AsciiString V13 = AsciiString.cached("13"); + + final HttpHeaders customHeaders; + final String expectedSubProtocol; + final int maxFramePayloadLength; + final URI uri; + + volatile String actualSubProtocol; + + volatile boolean handshakeComplete; + + WebsocketClientHandshaker(URI uri, @Nullable String subProtocol, HttpHeaders customHeaders, int maxFramePayloadLength) { + this.uri = uri; + this.expectedSubProtocol = subProtocol; + this.customHeaders = customHeaders; + this.maxFramePayloadLength = maxFramePayloadLength; + } + + /* + https://datatracker.ietf.org/doc/html/rfc8441#section-5.1 + + HEADERS + END_HEADERS + :status = 200 + sec-websocket-protocol = chat + */ + @SuppressWarnings("StringSplitter") + void finishHandshake(Channel channel, HttpResponse response) { + // Verify the subProtocol that we received from the server. + // This must be one of our expected subProtocols - or null/empty if we didn't want to speak a subProtocol + String receivedProtocol = response.headers().get(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL); + receivedProtocol = receivedProtocol != null ? receivedProtocol.trim() : null; + String expectedProtocol = expectedSubProtocol != null ? expectedSubProtocol : ""; + boolean protocolValid = false; + + if (expectedProtocol.isEmpty() && receivedProtocol == null) { + // No subProtocol required and none received + protocolValid = true; + this.actualSubProtocol = expectedSubProtocol; // null or "" - we echo what the user requested + } + else if (!expectedProtocol.isEmpty() && receivedProtocol != null && !receivedProtocol.isEmpty()) { + // We require a subProtocol and received one -> verify it + for (String protocol : expectedProtocol.split(",")) { + if (protocol.trim().equals(receivedProtocol)) { + protocolValid = true; + this.actualSubProtocol = receivedProtocol; + break; + } + } + } // else mixed cases - which are all errors + + if (!protocolValid) { + throw new WebSocketClientHandshakeException( + "Invalid subprotocol. Actual [" + receivedProtocol + "]. Expected one of [" + expectedSubProtocol + "]", response); + } + + handshakeComplete = true; + + ChannelPipeline p = channel.pipeline(); + + ChannelHandlerContext ctx = p.context("ws-encoder"); + if (ctx == null) { + throw new WebSocketClientHandshakeException( + "ChannelPipeline does not contain an ws-encoder", response); + } + else { + p.addAfter(ctx.name(), "ws-decoder", newWebsocketDecoder(maxFramePayloadLength)); + } + } + + ChannelFuture handshake(Channel channel) { + ChannelPromise promise = channel.newPromise(); + + ChannelPipeline pipeline = channel.pipeline(); + ChannelHandlerContext codec = pipeline.context(NettyPipeline.H2ToHttp11Codec); + if (codec == null) { + promise.setFailure(new WebSocketClientHandshakeException( + "ChannelPipeline does not contain an Http2StreamFrameToHttpObjectCodec")); + return promise; + } + + pipeline.addBefore(codec.name(), ProtocolHeaderHandler.NAME, ProtocolHeaderHandler.INSTANCE); + + HttpRequest request = newHandshakeRequest(); + + channel.writeAndFlush(request).addListener((ChannelFutureListener) future -> { + if (future.isSuccess()) { + ChannelPipeline p = future.channel().pipeline(); + ChannelHandlerContext ctx = p.context(NettyPipeline.HttpTrafficHandler); + if (ctx == null) { + promise.setFailure(new WebSocketClientHandshakeException( + "ChannelPipeline does not contain an Http2StreamBridgeClientHandler")); + return; + } + p.addAfter(ctx.name(), "ws-encoder", newWebsocketEncoder()); + p.replace(ctx.name(), WebsocketStreamBridgeClientHandler.NAME, WebsocketStreamBridgeClientHandler.INSTANCE); + + promise.setSuccess(); + } + else { + promise.setFailure(future.cause()); + } + }); + return promise; + } + + /* + https://datatracker.ietf.org/doc/html/rfc8441#section-5.1 + + HEADERS + END_HEADERS + :method = CONNECT + :protocol = websocket + :scheme = https + :path = /chat + :authority = server.example.com + sec-websocket-protocol = chat, superchat + sec-websocket-extensions = permessage-deflate + sec-websocket-version = 13 + origin = http://www.example.com + */ + HttpRequest newHandshakeRequest() { + HttpRequest request = new DefaultHttpRequest(HttpVersion.HTTP_1_1, HttpMethod.CONNECT, upgradeUrl(uri)); + HttpHeaders headers = request.headers(); + + headers.add(customHeaders); + + headers.set(HttpHeaderNames.HOST, websocketHostValue(uri)); + + if (!headers.contains(HttpHeaderNames.ORIGIN)) { + headers.set(HttpHeaderNames.ORIGIN, websocketOriginValue(uri)); + } + + if (expectedSubProtocol != null && !expectedSubProtocol.isEmpty()) { + headers.set(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL, expectedSubProtocol); + } + + headers.set(HttpHeaderNames.SEC_WEBSOCKET_VERSION, V13); + return request; + } + + static ChannelHandler newWebsocketDecoder(int maxFramePayloadLength) { + return new WebSocket13FrameDecoder(false, true, maxFramePayloadLength, false); + } + + static ChannelHandler newWebsocketEncoder() { + return new WebSocket13FrameEncoder(true); + } + + static String upgradeUrl(URI wsURL) { + String path = wsURL.getRawPath(); + path = path == null || path.isEmpty() ? "/" : path; + String query = wsURL.getRawQuery(); + return query != null && !query.isEmpty() ? path + '?' + query : path; + } + + static CharSequence websocketHostValue(URI wsURL) { + int port = wsURL.getPort(); + if (port == -1) { + return wsURL.getHost(); + } + String host = wsURL.getHost(); + String scheme = wsURL.getScheme(); + if (port == HttpScheme.HTTP.port()) { + return HttpScheme.HTTP.name().contentEquals(scheme) || + WebSocketScheme.WS.name().contentEquals(scheme) ? + host : NetUtil.toSocketAddressString(host, port); + } + if (port == HttpScheme.HTTPS.port()) { + return HttpScheme.HTTPS.name().contentEquals(scheme) || + WebSocketScheme.WSS.name().contentEquals(scheme) ? + host : NetUtil.toSocketAddressString(host, port); + } + + // if the port is not standard (80/443) it's needed to add the port to the header. + // See https://tools.ietf.org/html/rfc6454#section-6.2 + return NetUtil.toSocketAddressString(host, port); + } + + static CharSequence websocketOriginValue(URI wsURL) { + String scheme = wsURL.getScheme(); + final String schemePrefix; + int port = wsURL.getPort(); + final int defaultPort; + if (WebSocketScheme.WSS.name().contentEquals(scheme) || + HttpScheme.HTTPS.name().contentEquals(scheme) || + (scheme == null && port == WebSocketScheme.WSS.port())) { + + schemePrefix = HTTPS_SCHEME_PREFIX; + defaultPort = WebSocketScheme.WSS.port(); + } + else { + schemePrefix = HTTP_SCHEME_PREFIX; + defaultPort = WebSocketScheme.WS.port(); + } + + // Convert uri-host to lower case (by RFC 6454, chapter 4 "Origin of a URI") + String host = wsURL.getHost().toLowerCase(Locale.US); + + if (port != defaultPort && port != -1) { + // if the port is not standard (80/443) it's needed to add the port to the header. + // See https://tools.ietf.org/html/rfc6454#section-6.2 + return schemePrefix + NetUtil.toSocketAddressString(host, port); + } + return schemePrefix + host; + } + + static final class ProtocolHeaderHandler extends ChannelOutboundHandlerAdapter { + static final ProtocolHeaderHandler INSTANCE = new ProtocolHeaderHandler(); + static final String NAME = LEFT + "protocolHeaderHandler"; + + @Override + public boolean isSharable() { + return true; + } + + @Override + @SuppressWarnings("FutureReturnValueIgnored") + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { + if (msg instanceof Http2HeadersFrame) { + ((Http2HeadersFrame) msg).headers().set(Http2Headers.PseudoHeaderName.PROTOCOL.value(), HttpHeaderValues.WEBSOCKET); + ctx.pipeline().remove(this); + } + //"FutureReturnValueIgnored" this is deliberate + ctx.write(msg, promise); + } + } + + static final class WebsocketStreamBridgeClientHandler extends ChannelDuplexHandler { + static final WebsocketStreamBridgeClientHandler INSTANCE = new WebsocketStreamBridgeClientHandler(); + static final String NAME = LEFT + "websocketStreamBridgeClientHandler"; + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + if (!(msg instanceof FullHttpResponse) && msg instanceof HttpContent) { + ctx.fireChannelRead(((HttpContent) msg).content()); + } + else { + ctx.fireChannelRead(msg); + } + } + + @Override + public boolean isSharable() { + return true; + } + + @Override + @SuppressWarnings("FutureReturnValueIgnored") + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { + if (msg instanceof ByteBuf) { + //"FutureReturnValueIgnored" this is deliberate + ctx.write(new DefaultHttpContent((ByteBuf) msg), promise); + } + else { + //"FutureReturnValueIgnored" this is deliberate + ctx.write(msg, promise); + } + } + } + } +} diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java index 1839d78958..da3f86b7cd 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java @@ -40,6 +40,7 @@ import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.HttpUtil; import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakeException; import io.netty.handler.ssl.SslClosedEngineException; import io.netty.resolver.AddressResolverGroup; import io.netty.util.AsciiString; @@ -66,6 +67,8 @@ import reactor.util.retry.Retry; import static reactor.netty.ReactorNetty.format; +import static reactor.netty.http.Http2SettingsSpec.FALSE; +import static reactor.netty.http.client.HttpClientOperations.H2; import static reactor.netty.http.client.HttpClientState.STREAM_CONFIGURED; /** @@ -537,6 +540,7 @@ public SocketAddress get() { return address; } + @SuppressWarnings("ReferenceEquality") Publisher requestWithBody(HttpClientOperations ch) { try { ch.resourceUrl = this.resourceUrl; @@ -580,6 +584,18 @@ Publisher requestWithBody(HttpClientOperations ch) { ch.listener().onStateChange(ch, HttpClientState.REQUEST_PREPARED); if (websocketClientSpec != null) { + // ReferenceEquality is deliberate + if (ch.version == H2) { + Long value = ch.channel().parent().attr(ENABLE_CONNECT_PROTOCOL).get(); + if (value == null) { + throw new WebSocketClientHandshakeException("Websocket is not supported by the server. " + + "Missing SETTINGS_ENABLE_CONNECT_PROTOCOL(0x8)."); + } + if (FALSE.equals(value)) { + throw new WebSocketClientHandshakeException("Websocket is not supported by the server. " + + "[SETTINGS_ENABLE_CONNECT_PROTOCOL(0x8)=0] was received."); + } + } Mono result = Mono.fromRunnable(() -> ch.withWebsocketSupport(websocketClientSpec, compress)); if (handler != null) { @@ -721,6 +737,8 @@ public String toString() { static final AsciiString ALL = new AsciiString("*/*"); + static final AttributeKey ENABLE_CONNECT_PROTOCOL = AttributeKey.valueOf("$ENABLE_CONNECT_PROTOCOL"); + static final Logger log = Loggers.getLogger(HttpClientConnect.class); static final BiFunction URI_ADDRESS_MAPPER = AddressUtils::createUnresolved; diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientOperations.java index 75d6f6d528..7a437a53f7 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientOperations.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientOperations.java @@ -42,7 +42,6 @@ import io.netty.channel.ChannelHandlerContext; import io.netty.channel.socket.SocketChannel; import io.netty.channel.unix.DomainSocketChannel; -import io.netty.handler.codec.compression.ZlibCodecFactory; import io.netty.handler.codec.http.DefaultFullHttpRequest; import io.netty.handler.codec.http.DefaultHttpRequest; import io.netty.handler.codec.http.FullHttpResponse; @@ -51,7 +50,6 @@ import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.HttpMessage; import io.netty.handler.codec.http.HttpMethod; -import io.netty.handler.codec.http.HttpObjectAggregator; import io.netty.handler.codec.http.HttpRequest; import io.netty.handler.codec.http.HttpResponse; import io.netty.handler.codec.http.HttpResponseStatus; @@ -64,9 +62,6 @@ import io.netty.handler.codec.http.multipart.DefaultHttpDataFactory; import io.netty.handler.codec.http.multipart.HttpDataFactory; import io.netty.handler.codec.http.multipart.HttpPostRequestEncoder; -import io.netty.handler.codec.http.websocketx.extensions.WebSocketClientExtensionHandler; -import io.netty.handler.codec.http.websocketx.extensions.compression.DeflateFrameClientExtensionHandshaker; -import io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateClientExtensionHandshaker; import io.netty.handler.codec.http2.Http2StreamChannel; import io.netty.handler.stream.ChunkedWriteHandler; import io.netty.handler.timeout.ReadTimeoutHandler; @@ -97,7 +92,6 @@ import reactor.util.annotation.Nullable; import reactor.util.context.ContextView; -import static io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateServerExtensionHandshaker.MAX_WINDOW_SIZE; import static reactor.netty.ReactorNetty.format; /** @@ -413,7 +407,9 @@ public boolean isKeepAlive() { @Override public boolean isWebsocket() { ChannelOperations ops = get(channel()); - return ops != null && ops.getClass().equals(WebsocketClientOperations.class); + return ops != null && + (ops.getClass().equals(WebsocketClientOperations.class) || + ops.getClass().equals(Http2WebsocketClientOperations.class)); } @Override @@ -931,35 +927,22 @@ final void setNettyResponse(HttpResponse nettyResponse) { } } - @SuppressWarnings("FutureReturnValueIgnored") + @SuppressWarnings("ReferenceEquality") final void withWebsocketSupport(WebsocketClientSpec websocketClientSpec, boolean compress) { URI url = websocketUri(); //prevent further header to be sent for handshaking if (markSentHeaders()) { - // Returned value is deliberately ignored - addHandlerFirst(NettyPipeline.HttpAggregator, new HttpObjectAggregator(8192)); - removeHandler(NettyPipeline.HttpMetricsHandler); - - if (websocketClientSpec.compress()) { - requestHeaders().remove(HttpHeaderNames.ACCEPT_ENCODING); - // Returned value is deliberately ignored - removeHandler(NettyPipeline.HttpDecompressor); - // Returned value is deliberately ignored - PerMessageDeflateClientExtensionHandshaker perMessageDeflateClientExtensionHandshaker = - new PerMessageDeflateClientExtensionHandshaker(6, ZlibCodecFactory.isSupportingWindowSizeAndMemLevel(), - MAX_WINDOW_SIZE, websocketClientSpec.compressionAllowClientNoContext(), - websocketClientSpec.compressionRequestedServerNoContext()); - addHandlerFirst(NettyPipeline.WsCompressionHandler, - new WebSocketClientExtensionHandler( - perMessageDeflateClientExtensionHandshaker, - new DeflateFrameClientExtensionHandshaker(false), - new DeflateFrameClientExtensionHandshaker(true))); - } - if (log.isDebugEnabled()) { log.debug(format(channel(), "Attempting to perform websocket handshake with {}"), url); } - WebsocketClientOperations ops = new WebsocketClientOperations(url, websocketClientSpec, this); + WebsocketClientOperations ops; + // ReferenceEquality is deliberate + if (version == H2) { + ops = new Http2WebsocketClientOperations(url, websocketClientSpec, this); + } + else { + ops = new WebsocketClientOperations(url, websocketClientSpec, this); + } if (!rebind(ops)) { log.error(format(channel(), "Error while rebinding websocket in channel attribute: " + diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpTrafficHandler.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpTrafficHandler.java index 159a99dc46..2e288b4892 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpTrafficHandler.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpTrafficHandler.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2023 VMware, Inc. or its affiliates, All Rights Reserved. + * Copyright (c) 2020-2025 VMware, Inc. or its affiliates, All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,6 +30,8 @@ import static io.netty.handler.codec.http.HttpClientUpgradeHandler.UpgradeEvent.UPGRADE_REJECTED; import static io.netty.handler.codec.http.HttpClientUpgradeHandler.UpgradeEvent.UPGRADE_SUCCESSFUL; import static reactor.netty.ReactorNetty.format; +import static reactor.netty.http.Http2SettingsSpec.SETTINGS_ENABLE_CONNECT_PROTOCOL; +import static reactor.netty.http.client.HttpClientConnect.ENABLE_CONNECT_PROTOCOL; /** * {@link ChannelInboundHandlerAdapter} prior {@link reactor.netty.channel.ChannelOperationsHandler} @@ -74,6 +76,7 @@ else if (ctx.pipeline().get(NettyPipeline.SslHandler) == null) { @Override public void channelRead(ChannelHandlerContext ctx, Object msg) { if (msg instanceof Http2SettingsFrame) { + ctx.channel().attr(ENABLE_CONNECT_PROTOCOL).set(((Http2SettingsFrame) msg).settings().get(SETTINGS_ENABLE_CONNECT_PROTOCOL)); sendNewState(Connection.from(ctx.channel()), ConnectionObserver.State.CONFIGURED); ctx.pipeline().remove(NettyPipeline.ReactiveBridge); ctx.pipeline().remove(this); diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/WebsocketClientOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/WebsocketClientOperations.java index a2ee1dda3c..46c4a60357 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/WebsocketClientOperations.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/WebsocketClientOperations.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011-2023 VMware, Inc. or its affiliates, All Rights Reserved. + * Copyright (c) 2011-2025 VMware, Inc. or its affiliates, All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -22,11 +22,11 @@ import io.netty.channel.Channel; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.compression.ZlibCodecFactory; import io.netty.handler.codec.http.FullHttpResponse; import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.HttpObjectAggregator; -import io.netty.handler.codec.http.LastHttpContent; import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame; import io.netty.handler.codec.http.websocketx.PingWebSocketFrame; import io.netty.handler.codec.http.websocketx.PongWebSocketFrame; @@ -34,18 +34,24 @@ import io.netty.handler.codec.http.websocketx.WebSocketClientHandshaker; import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakerFactory; import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus; +import io.netty.handler.codec.http.websocketx.extensions.WebSocketClientExtensionHandler; +import io.netty.handler.codec.http.websocketx.extensions.compression.DeflateFrameClientExtensionHandshaker; +import io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateClientExtensionHandshaker; import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; import reactor.netty.FutureMono; import reactor.netty.NettyOutbound; +import reactor.netty.NettyPipeline; import reactor.netty.ReactorNetty; import reactor.netty.http.HttpOperations; import reactor.netty.http.websocket.WebsocketInbound; import reactor.netty.http.websocket.WebsocketOutbound; import reactor.util.annotation.Nullable; +import static io.netty.handler.codec.http.LastHttpContent.EMPTY_LAST_CONTENT; +import static io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateServerExtensionHandshaker.MAX_WINDOW_SIZE; import static reactor.netty.ReactorNetty.format; /** @@ -54,10 +60,10 @@ * @author Stephane Maldini * @author Simon Baslé */ -final class WebsocketClientOperations extends HttpClientOperations +class WebsocketClientOperations extends HttpClientOperations implements WebsocketInbound, WebsocketOutbound { - final WebSocketClientHandshaker handshaker; + WebSocketClientHandshaker handshakerHttp11; final Sinks.One onCloseState; final boolean proxyPing; @@ -70,23 +76,46 @@ final class WebsocketClientOperations extends HttpClientOperations HttpClientOperations replaced) { super(replaced); this.proxyPing = websocketClientSpec.handlePing(); - Channel channel = channel(); onCloseState = Sinks.unsafe().one(); + initHandshaker(currentURI, websocketClientSpec); + } + + void initHandshaker(URI currentURI, WebsocketClientSpec websocketClientSpec) { + // Returned value is deliberately ignored + addHandlerFirst(NettyPipeline.HttpAggregator, new HttpObjectAggregator(8192)); + + removeHandler(NettyPipeline.HttpMetricsHandler); + + if (websocketClientSpec.compress()) { + requestHeaders().remove(HttpHeaderNames.ACCEPT_ENCODING); + // Returned value is deliberately ignored + removeHandler(NettyPipeline.HttpDecompressor); + // Returned value is deliberately ignored + PerMessageDeflateClientExtensionHandshaker perMessageDeflateClientExtensionHandshaker = + new PerMessageDeflateClientExtensionHandshaker(6, ZlibCodecFactory.isSupportingWindowSizeAndMemLevel(), + MAX_WINDOW_SIZE, websocketClientSpec.compressionAllowClientNoContext(), + websocketClientSpec.compressionRequestedServerNoContext()); + addHandlerFirst(NettyPipeline.WsCompressionHandler, + new WebSocketClientExtensionHandler( + perMessageDeflateClientExtensionHandshaker, + new DeflateFrameClientExtensionHandshaker(false), + new DeflateFrameClientExtensionHandshaker(true))); + } String subprotocols = websocketClientSpec.protocols(); - handshaker = WebSocketClientHandshakerFactory.newHandshaker(currentURI, - websocketClientSpec.version(), - subprotocols != null && !subprotocols.isEmpty() ? subprotocols : null, - true, - replaced.requestHeaders() - .remove(HttpHeaderNames.HOST), - websocketClientSpec.maxFramePayloadLength()); - - handshaker.handshake(channel) - .addListener(f -> { - markPersistent(false); - channel.read(); - }); + handshakerHttp11 = WebSocketClientHandshakerFactory.newHandshaker(currentURI, + websocketClientSpec.version(), + subprotocols != null && !subprotocols.isEmpty() ? subprotocols : null, + true, + requestHeaders().remove(HttpHeaderNames.HOST), + websocketClientSpec.maxFramePayloadLength()); + + Channel channel = channel(); + handshakerHttp11.handshake(channel) + .addListener(f -> { + markPersistent(false); + channel.read(); + }); } @Override @@ -102,7 +131,7 @@ public boolean isWebsocket() { @Override @Nullable public String selectedSubprotocol() { - return handshaker.actualSubprotocol(); + return handshakerHttp11.actualSubprotocol(); } @Override @@ -118,7 +147,7 @@ public void onInboundNext(ChannelHandlerContext ctx, Object msg) { if (notRedirected(response)) { try { - handshaker.finishHandshake(channel(), response); + handshakerHttp11.finishHandshake(channel(), response); // This change is needed after the Netty change https://github.com/netty/netty/pull/11966 ctx.read(); listener().onStateChange(this, HttpClientState.RESPONSE_RECEIVED); @@ -162,7 +191,7 @@ public void onInboundNext(ChannelHandlerContext ctx, Object msg) { } onInboundComplete(); } - else if (msg != LastHttpContent.EMPTY_LAST_CONTENT) { + else if (msg != EMPTY_LAST_CONTENT) { super.onInboundNext(ctx, msg); } } @@ -177,7 +206,7 @@ protected void onInboundCancel() { @Override protected void onInboundClose() { - if (handshaker.isHandshakeComplete()) { + if (isHandshakeComplete()) { terminate(); } else { @@ -186,6 +215,10 @@ protected void onInboundClose() { } } + boolean isHandshakeComplete() { + return handshakerHttp11.isHandshakeComplete(); + } + @Override protected void onOutboundComplete() { } diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/server/Http2WebsocketServerOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/server/Http2WebsocketServerOperations.java new file mode 100644 index 0000000000..f0c40cd32e --- /dev/null +++ b/reactor-netty-http/src/main/java/reactor/netty/http/server/Http2WebsocketServerOperations.java @@ -0,0 +1,600 @@ +/* + * Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved. + * + * 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 + * + * https://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 reactor.netty.http.server; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.Channel; +import io.netty.channel.ChannelDuplexHandler; +import io.netty.channel.ChannelFuture; +import io.netty.channel.ChannelFutureListener; +import io.netty.channel.ChannelHandler; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.ChannelPromise; +import io.netty.handler.codec.compression.ZlibCodecFactory; +import io.netty.handler.codec.http.DefaultFullHttpResponse; +import io.netty.handler.codec.http.DefaultHttpContent; +import io.netty.handler.codec.http.DefaultHttpRequest; +import io.netty.handler.codec.http.DefaultHttpResponse; +import io.netty.handler.codec.http.FullHttpRequest; +import io.netty.handler.codec.http.HttpContent; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpHeaderValues; +import io.netty.handler.codec.http.HttpHeaders; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponse; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.HttpVersion; +import io.netty.handler.codec.http.LastHttpContent; +import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame; +import io.netty.handler.codec.http.websocketx.WebSocket13FrameDecoder; +import io.netty.handler.codec.http.websocketx.WebSocket13FrameEncoder; +import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus; +import io.netty.handler.codec.http.websocketx.WebSocketDecoderConfig; +import io.netty.handler.codec.http.websocketx.WebSocketFrameDecoder; +import io.netty.handler.codec.http.websocketx.WebSocketFrameEncoder; +import io.netty.handler.codec.http.websocketx.WebSocketServerHandshakeException; +import io.netty.handler.codec.http.websocketx.WebSocketVersion; +import io.netty.handler.codec.http.websocketx.extensions.WebSocketExtensionData; +import io.netty.handler.codec.http.websocketx.extensions.WebSocketExtensionDecoder; +import io.netty.handler.codec.http.websocketx.extensions.WebSocketExtensionEncoder; +import io.netty.handler.codec.http.websocketx.extensions.WebSocketExtensionUtil; +import io.netty.handler.codec.http.websocketx.extensions.WebSocketServerExtension; +import io.netty.handler.codec.http.websocketx.extensions.WebSocketServerExtensionHandshaker; +import io.netty.handler.codec.http.websocketx.extensions.compression.DeflateFrameServerExtensionHandshaker; +import io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateServerExtensionHandshaker; +import io.netty.util.AsciiString; +import io.netty.util.internal.EmptyArrays; +import org.reactivestreams.Subscriber; +import reactor.core.publisher.Mono; +import reactor.netty.FutureMono; +import reactor.netty.NettyPipeline; +import reactor.netty.ReactorNetty; +import reactor.util.annotation.Nullable; +import reactor.util.context.Context; +import reactor.util.context.ContextView; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Queue; + +import static io.netty.buffer.Unpooled.EMPTY_BUFFER; +import static io.netty.handler.codec.http.HttpMethod.CONNECT; +import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1; +import static io.netty.handler.codec.http.LastHttpContent.EMPTY_LAST_CONTENT; +import static io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateServerExtensionHandshaker.MAX_WINDOW_SIZE; +import static reactor.netty.NettyPipeline.LEFT; +import static reactor.netty.ReactorNetty.format; + +final class Http2WebsocketServerOperations extends WebsocketServerOperations { + static final AsciiString V13 = AsciiString.cached("13"); + + WebsocketServerHandshaker handshakerHttp2; + + Http2WebsocketServerOperations(String wsUrl, WebsocketServerSpec websocketServerSpec, HttpServerOperations replaced) { + super(wsUrl, websocketServerSpec, replaced); + } + + @Override + @Nullable + public String selectedSubprotocol() { + return handshakerHttp2.selectedSubProtocol; + } + + @Override + protected void onInboundCancel() { + if (log.isDebugEnabled()) { + log.debug(format(channel(), INBOUND_CANCEL_LOG)); + } + sendCloseNow(new CloseWebSocketFrame(), WebSocketCloseStatus.ABNORMAL_CLOSURE, EMPTY); + } + + @Override + void initHandshaker(String wsUrl, WebsocketServerSpec websocketServerSpec, HttpServerOperations replaced) { + handshakerResult = channel().newPromise(); + + if (isValid()) { + Channel channel = channel(); + + removeHandler(NettyPipeline.AccessLogHandler); + + ChannelHandler handler = channel.pipeline().get(NettyPipeline.HttpMetricsHandler); + if (handler != null) { + replaceHandler(NettyPipeline.HttpMetricsHandler, + new WebsocketHttpServerMetricsHandler((AbstractHttpServerMetricsHandler) handler)); + } + + HttpRequest request = new DefaultHttpRequest(replaced.version(), replaced.method(), replaced.uri()); + request.headers().set(replaced.nettyRequest.headers()); + + if (websocketServerSpec.compress()) { + removeHandler(NettyPipeline.CompressionHandler); + + PerMessageDeflateServerExtensionHandshaker perMessageDeflateServerExtensionHandshaker = + new PerMessageDeflateServerExtensionHandshaker(6, ZlibCodecFactory.isSupportingWindowSizeAndMemLevel(), + MAX_WINDOW_SIZE, websocketServerSpec.compressionAllowServerNoContext(), + websocketServerSpec.compressionPreferredClientNoContext()); + WebsocketServerExtensionHandler wsServerExtensionHandler = + new WebsocketServerExtensionHandler(Arrays.asList( + perMessageDeflateServerExtensionHandshaker, + new DeflateFrameServerExtensionHandshaker())); + try { + ChannelPipeline pipeline = channel.pipeline(); + wsServerExtensionHandler.channelRead(pipeline.context(NettyPipeline.ReactiveBridge), request); + + if (pipeline.get(NettyPipeline.HttpTrafficHandler) != null) { + pipeline.addAfter(NettyPipeline.HttpTrafficHandler, NettyPipeline.WsCompressionHandler, wsServerExtensionHandler); + } + } + catch (Throwable e) { + log.error(format(channel, ""), e); + } + } + + handshakerHttp2 = new WebsocketServerHandshaker(wsUrl, websocketServerSpec); + handshakerHttp2.handshake(channel, request, responseHeaders.remove(HttpHeaderNames.TRANSFER_ENCODING), handshakerResult) + .addListener(f -> { + if (replaced.rebind(this)) { + markPersistent(false); + // This change is needed after the Netty change https://github.com/netty/netty/pull/11966 + channel.read(); + } + else if (log.isDebugEnabled()) { + log.debug(format(channel, "Cannot bind Http2WebsocketServerOperations after the handshake.")); + } + }); + } + } + + @SuppressWarnings("UndefinedEquals") + boolean isValid() { + String msg = null; + if (this.nettyRequest instanceof FullHttpRequest) { + msg = "Failed to upgrade to websocket. End of stream is received."; + } + else if (!CONNECT.equals(method())) { + msg = "Invalid websocket request handshake method [" + method() + "]."; + } + else if (!requestHeaders().contains("x-protocol", HttpHeaderValues.WEBSOCKET, true)) { + msg = "Invalid websocket request, missing [:protocol=websocket] header."; + } + else { + CharSequence version = requestHeaders().get(HttpHeaderNames.SEC_WEBSOCKET_VERSION); + if (version == null || !version.equals(WebSocketVersion.V13.toHttpHeaderValue())) { + msg = "Websocket version [" + version + "] is not supported."; + } + } + + if (msg != null) { + HttpResponse res = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.BAD_REQUEST, EMPTY_BUFFER); + res.headers().set(HttpHeaderNames.CONTENT_LENGTH, "0"); + Throwable handshakeException = new WebSocketServerHandshakeException(msg, nettyRequest); + channel().writeAndFlush(res) + .addListener(f -> handshakerResult.setFailure(handshakeException)); + + return false; + } + else { + return true; + } + } + + @Override + @SuppressWarnings("FutureReturnValueIgnored") + Mono sendClose(CloseWebSocketFrame frame) { + if (CLOSE_SENT.get(this) == 0) { + //commented for now as we assume the close is always scheduled (deferFuture runs) + //onTerminate().subscribe(null, null, () -> ReactorNetty.safeRelease(frame)); + return FutureMono.deferFuture(() -> { + if (CLOSE_SENT.getAndSet(this, 1) == 0) { + discard(); + // EmitResult is ignored as CLOSE_SENT guarantees that there will be only one emission + // Whether there are subscribers or the subscriber cancels is not of interest + // Evaluated EmitResult: FAIL_TERMINATED, FAIL_OVERFLOW, FAIL_CANCELLED, FAIL_NON_SERIALIZED + // FAIL_ZERO_SUBSCRIBER + onCloseState.tryEmitValue(new WebSocketCloseStatus(frame.statusCode(), frame.reasonText())); + //"FutureReturnValueIgnored" this is deliberate + channel().write(frame); + return channel().writeAndFlush(EMPTY_LAST_CONTENT) + .addListener(ChannelFutureListener.CLOSE); + } + frame.release(); + return channel().newSucceededFuture(); + }).doOnCancel(() -> ReactorNetty.safeRelease(frame)); + } + frame.release(); + return Mono.empty(); + } + + @Override + @SuppressWarnings("FutureReturnValueIgnored") + void sendCloseNow(CloseWebSocketFrame frame, WebSocketCloseStatus closeStatus, ChannelFutureListener listener) { + if (!frame.isFinalFragment()) { + //"FutureReturnValueIgnored" this is deliberate + channel().writeAndFlush(frame); + return; + } + if (CLOSE_SENT.getAndSet(this, 1) == 0) { + // EmitResult is ignored as CLOSE_SENT guarantees that there will be only one emission + // Whether there are subscribers or the subscriber cancels is not of interest + // Evaluated EmitResult: FAIL_TERMINATED, FAIL_OVERFLOW, FAIL_CANCELLED, FAIL_NON_SERIALIZED + // FAIL_ZERO_SUBSCRIBER + onCloseState.tryEmitValue(closeStatus); + //"FutureReturnValueIgnored" this is deliberate + channel().write(frame); + channel().writeAndFlush(EMPTY_LAST_CONTENT) + .addListener(listener); + } + else { + frame.release(); + } + } + + static final ChannelFutureListener EMPTY = f -> {}; + + @Override + Subscriber websocketSubscriber(ContextView contextView) { + return new WebsocketSubscriber(this, Context.of(contextView), EMPTY); + } + + /* This class is based on io.netty.handler.codec.http.websocketx.WebSocketServerHandshaker but adapted for HTTP/2 */ + static final class WebsocketServerHandshaker { + /** + * Use this as wildcard to support all requested sub-protocols. + */ + static final String SUB_PROTOCOL_WILDCARD = "*"; + + final String uri; + final String[] subProtocols; + final WebSocketDecoderConfig decoderConfig; + + String selectedSubProtocol; + + WebsocketServerHandshaker(String uri, WebsocketServerSpec websocketServerSpec) { + this.uri = uri; + String protocols = websocketServerSpec.protocols(); + if (protocols != null) { + String[] subProtocolArray = protocols.split(","); + for (int i = 0; i < subProtocolArray.length; i++) { + subProtocolArray[i] = subProtocolArray[i].trim(); + } + this.subProtocols = subProtocolArray; + } + else { + this.subProtocols = EmptyArrays.EMPTY_STRINGS; + } + this.decoderConfig = + WebSocketDecoderConfig.newBuilder() + .allowExtensions(true) + .maxFramePayloadLength(websocketServerSpec.maxFramePayloadLength()) + .allowMaskMismatch(false) + .build(); + } + + ChannelFuture handshake(Channel channel, HttpRequest req, HttpHeaders responseHeaders, ChannelPromise promise) { + HttpResponse response = newHandshakeResponse(req.headers().get(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL), responseHeaders); + + ChannelPipeline p = channel.pipeline(); + channel.writeAndFlush(response).addListener(future -> { + if (future.isSuccess()) { + ChannelHandlerContext ctx = p.context(NettyPipeline.HttpTrafficHandler); + p.addAfter(ctx.name(), "wsdecoder", newWebsocketDecoder(decoderConfig)); + p.addAfter(ctx.name(), "wsencoder", newWebsocketEncoder()); + p.replace(ctx.name(), WebsocketStreamBridgeServerHandler.NAME, WebsocketStreamBridgeServerHandler.INSTANCE); + + promise.setSuccess(); + } + else { + promise.setFailure(future.cause()); + } + }); + return promise; + } + + @Nullable + @SuppressWarnings("StringSplitter") + String selectSubProtocol(@Nullable String requestedSubProtocols) { + if (requestedSubProtocols == null || subProtocols.length == 0) { + return null; + } + + String[] requestedSubProtocolArray = requestedSubProtocols.split(","); + for (String p : requestedSubProtocolArray) { + String requestedSubProtocol = p.trim(); + + for (String supportedSubProtocol : subProtocols) { + if (SUB_PROTOCOL_WILDCARD.equals(supportedSubProtocol) + || requestedSubProtocol.equals(supportedSubProtocol)) { + selectedSubProtocol = requestedSubProtocol; + return requestedSubProtocol; + } + } + } + + return null; + } + + /* + https://datatracker.ietf.org/doc/html/rfc8441#section-5.1 + + HEADERS + END_HEADERS + :method = CONNECT + :protocol = websocket + :scheme = https + :path = /chat + :authority = server.example.com + sec-websocket-protocol = chat, superchat + sec-websocket-extensions = permessage-deflate + sec-websocket-version = 13 + origin = http://www.example.com + + HEADERS + END_HEADERS + :status = 200 + sec-websocket-protocol = chat + */ + HttpResponse newHandshakeResponse(@Nullable String subProtocols, HttpHeaders headers) { + HttpResponse res = new DefaultHttpResponse(HTTP_1_1, HttpResponseStatus.OK); + res.headers().add(headers); + + if (subProtocols != null) { + String selectedSubProtocol = selectSubProtocol(subProtocols); + if (selectedSubProtocol == null) { + if (log.isDebugEnabled()) { + log.debug("Requested subprotocol(s) not supported: {}", subProtocols); + } + } + else { + res.headers().set(HttpHeaderNames.SEC_WEBSOCKET_PROTOCOL, selectedSubProtocol); + } + } + + return res; + } + + static WebSocketFrameDecoder newWebsocketDecoder(WebSocketDecoderConfig decoderConfig) { + return new WebSocket13FrameDecoder(decoderConfig); + } + + static WebSocketFrameEncoder newWebsocketEncoder() { + return new WebSocket13FrameEncoder(false); + } + + static final class WebsocketStreamBridgeServerHandler extends ChannelDuplexHandler { + static final WebsocketStreamBridgeServerHandler INSTANCE = new WebsocketStreamBridgeServerHandler(); + static final String NAME = LEFT + "websocketStreamBridgeServerHandler"; + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + if (!(msg instanceof FullHttpRequest) && msg instanceof HttpContent) { + ctx.fireChannelRead(((HttpContent) msg).content()); + } + else { + ctx.fireChannelRead(msg); + } + } + + @Override + public boolean isSharable() { + return true; + } + + @Override + @SuppressWarnings("FutureReturnValueIgnored") + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { + if (msg instanceof ByteBuf) { + //"FutureReturnValueIgnored" this is deliberate + ctx.write(new DefaultHttpContent((ByteBuf) msg), promise); + } + else { + //"FutureReturnValueIgnored" this is deliberate + ctx.write(msg, promise); + } + } + } + } + + /* This class is based on io.netty.handler.codec.http.websocketx.extensions.WebSocketServerExtensionHandler but adapted for HTTP/2 */ + static final class WebsocketServerExtensionHandler extends ChannelDuplexHandler { + + final List extensionHandshakers; + + final Queue> validExtensions = new ArrayDeque<>(4); + + WebsocketServerExtensionHandler(List extensionHandshakers) { + this.extensionHandshakers = extensionHandshakers; + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + if (msg != LastHttpContent.EMPTY_LAST_CONTENT) { + if (msg instanceof DefaultHttpRequest) { + onHttpRequestChannelRead(ctx, (DefaultHttpRequest) msg); + } + else if (msg instanceof HttpRequest) { + onHttpRequestChannelRead(ctx, (HttpRequest) msg); + } + else { + ctx.fireChannelRead(msg); + } + } + else { + ctx.fireChannelRead(msg); + } + } + + void onHttpRequestChannelRead(ChannelHandlerContext ctx, HttpRequest request) { + List validExtensionsList = null; + + String extensionsHeader = request.headers().getAsString(HttpHeaderNames.SEC_WEBSOCKET_EXTENSIONS); + + if (extensionsHeader != null) { + List extensions = WebSocketExtensionUtil.extractExtensions(extensionsHeader); + int rsv = 0; + + for (WebSocketExtensionData extensionData : extensions) { + Iterator extensionHandshakersIterator = + extensionHandshakers.iterator(); + WebSocketServerExtension validExtension = null; + + while (validExtension == null && extensionHandshakersIterator.hasNext()) { + WebSocketServerExtensionHandshaker extensionHandshaker = + extensionHandshakersIterator.next(); + validExtension = extensionHandshaker.handshakeExtension(extensionData); + } + + if (validExtension != null && ((validExtension.rsv() & rsv) == 0)) { + if (validExtensionsList == null) { + validExtensionsList = new ArrayList<>(1); + } + rsv = rsv | validExtension.rsv(); + validExtensionsList.add(validExtension); + } + } + } + + if (validExtensionsList == null) { + validExtensionsList = Collections.emptyList(); + } + validExtensions.offer(validExtensionsList); + ctx.fireChannelRead(request); + } + + @Override + @SuppressWarnings("FutureReturnValueIgnored") + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { + if (msg != Unpooled.EMPTY_BUFFER && !(msg instanceof ByteBuf)) { + if (msg.getClass() == DefaultHttpResponse.class) { + onHttpResponseWrite(ctx, (DefaultHttpResponse) msg, promise); + } + else if (msg instanceof HttpResponse) { + onHttpResponseWrite(ctx, (HttpResponse) msg, promise); + } + else { + //"FutureReturnValueIgnored" this is deliberate + ctx.write(msg, promise); + } + } + else { + //"FutureReturnValueIgnored" this is deliberate + ctx.write(msg, promise); + } + } + + @SuppressWarnings("FutureReturnValueIgnored") + void onHttpResponseWrite(ChannelHandlerContext ctx, HttpResponse response, ChannelPromise promise) { + List validExtensionsList = validExtensions.poll(); + if (HttpResponseStatus.OK.equals(response.status())) { + handlePotentialUpgrade(ctx, promise, response, validExtensionsList); + } + //"FutureReturnValueIgnored" this is deliberate + ctx.write(response, promise); + } + + void handlePotentialUpgrade(ChannelHandlerContext ctx, ChannelPromise promise, HttpResponse httpResponse, + @Nullable List validExtensionsList) { + HttpHeaders headers = httpResponse.headers(); + + if (validExtensionsList != null && !validExtensionsList.isEmpty()) { + String headerValue = headers.getAsString(HttpHeaderNames.SEC_WEBSOCKET_EXTENSIONS); + List extraExtensions = new ArrayList<>(extensionHandshakers.size()); + for (WebSocketServerExtension extension : validExtensionsList) { + extraExtensions.add(extension.newReponseData()); + } + String newHeaderValue = computeMergeExtensionsHeaderValue(headerValue, extraExtensions); + promise.addListener(future -> { + if (future.isSuccess()) { + for (WebSocketServerExtension extension : validExtensionsList) { + WebSocketExtensionDecoder decoder = extension.newExtensionDecoder(); + WebSocketExtensionEncoder encoder = extension.newExtensionEncoder(); + String name = ctx.name(); + ctx.pipeline() + .addAfter(name, decoder.getClass().getName(), decoder) + .addAfter(name, encoder.getClass().getName(), encoder); + } + } + }); + + if (!newHeaderValue.isEmpty()) { + headers.set(HttpHeaderNames.SEC_WEBSOCKET_EXTENSIONS, newHeaderValue); + } + } + + promise.addListener(future -> { + if (future.isSuccess()) { + ctx.pipeline().remove(WebsocketServerExtensionHandler.this); + } + }); + } + + static final String EXTENSION_SEPARATOR = ","; + static final String PARAMETER_SEPARATOR = ";"; + static final char PARAMETER_EQUAL = '='; + + static String computeMergeExtensionsHeaderValue(@Nullable String userDefinedHeaderValue, List extraExtensions) { + List userDefinedExtensions = + userDefinedHeaderValue != null ? WebSocketExtensionUtil.extractExtensions(userDefinedHeaderValue) : Collections.emptyList(); + + for (WebSocketExtensionData userDefined : userDefinedExtensions) { + WebSocketExtensionData matchingExtra = null; + int i; + for (i = 0; i < extraExtensions.size(); i++) { + WebSocketExtensionData extra = extraExtensions.get(i); + if (extra.name().equals(userDefined.name())) { + matchingExtra = extra; + break; + } + } + if (matchingExtra == null) { + extraExtensions.add(userDefined); + } + else { + // merge with higher precedence to user defined parameters + Map mergedParameters = new HashMap<>(matchingExtra.parameters()); + mergedParameters.putAll(userDefined.parameters()); + extraExtensions.set(i, new WebSocketExtensionData(matchingExtra.name(), mergedParameters)); + } + } + + StringBuilder sb = new StringBuilder(150); + + for (WebSocketExtensionData data : extraExtensions) { + sb.append(data.name()); + for (Map.Entry parameter : data.parameters().entrySet()) { + sb.append(PARAMETER_SEPARATOR); + sb.append(parameter.getKey()); + if (parameter.getValue() != null) { + sb.append(PARAMETER_EQUAL); + sb.append(parameter.getValue()); + } + } + sb.append(EXTENSION_SEPARATOR); + } + + if (!extraExtensions.isEmpty()) { + sb.setLength(sb.length() - EXTENSION_SEPARATOR.length()); + } + + return sb.toString(); + } + } +} diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerConfig.java b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerConfig.java index 08a6243cdb..d729c673e6 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerConfig.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerConfig.java @@ -19,6 +19,7 @@ import io.netty.channel.ChannelHandler; import io.netty.channel.ChannelHandlerAdapter; import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelOption; import io.netty.channel.ChannelPipeline; @@ -41,6 +42,8 @@ import io.netty.handler.codec.http2.Http2FrameCodec; import io.netty.handler.codec.http2.Http2FrameCodecBuilder; import io.netty.handler.codec.http2.Http2FrameLogger; +import io.netty.handler.codec.http2.Http2Headers; +import io.netty.handler.codec.http2.Http2HeadersFrame; import io.netty.handler.codec.http2.Http2MultiplexHandler; import io.netty.handler.codec.http2.Http2ServerUpgradeCodec; import io.netty.handler.codec.http2.Http2Settings; @@ -93,8 +96,12 @@ import java.util.function.Function; import java.util.function.Supplier; +import static reactor.netty.NettyPipeline.LEFT; import static reactor.netty.ReactorNetty.ACCESS_LOG_ENABLED; import static reactor.netty.ReactorNetty.format; +import static reactor.netty.http.Http2SettingsSpec.FALSE; +import static reactor.netty.http.Http2SettingsSpec.SETTINGS_ENABLE_CONNECT_PROTOCOL; +import static reactor.netty.http.Http2SettingsSpec.TRUE; import static reactor.netty.http.server.Http3Codec.newHttp3ServerConnectionHandler; import static reactor.netty.http.server.HttpServerFormDecoderProvider.DEFAULT_FORM_DECODER_SPEC; @@ -451,6 +458,11 @@ static Http2Settings http2Settings(@Nullable Http2SettingsSpec http2Settings) { Http2Settings settings = Http2Settings.defaultSettings(); if (http2Settings != null) { + Boolean connectProtocolEnabled = http2Settings.connectProtocolEnabled(); + if (connectProtocolEnabled != null) { + settings.put(SETTINGS_ENABLE_CONNECT_PROTOCOL, connectProtocolEnabled ? TRUE : FALSE); + } + Long headerTableSize = http2Settings.headerTableSize(); if (headerTableSize != null) { settings.headerTableSize(headerTableSize); @@ -487,6 +499,7 @@ static void addStreamHandlers(Channel ch, @Nullable Function accessLog, @Nullable HttpCompressionOptionsSpec compressionOptions, @Nullable BiPredicate compressPredicate, + @Nullable Boolean connectProtocolEnabled, ServerCookieDecoder decoder, ServerCookieEncoder encoder, HttpServerFormDecoderProvider formDecoderProvider, @@ -505,6 +518,9 @@ static void addStreamHandlers(Channel ch, if (accessLogEnabled) { pipeline.addLast(NettyPipeline.AccessLogHandler, AccessLogHandlerFactory.H2.create(accessLog)); } + if (Boolean.TRUE.equals(connectProtocolEnabled)) { + pipeline.addLast(ProtocolHeaderHandler.NAME, ProtocolHeaderHandler.INSTANCE); + } pipeline.addLast(NettyPipeline.H2ToHttp11Codec, HTTP2_STREAM_FRAME_TO_HTTP_OBJECT) .addLast(NettyPipeline.HttpTrafficHandler, new Http2StreamBridgeServerHandler(compressPredicate, compressionOptions, decoder, encoder, formDecoderProvider, @@ -681,8 +697,9 @@ static void configureH2Pipeline(ChannelPipeline p, } p.addLast(NettyPipeline.HttpCodec, http2FrameCodec) .addLast(NettyPipeline.H2MultiplexHandler, - new Http2MultiplexHandler(new H2Codec(accessLogEnabled, accessLog, compressionOptions, compressPredicate, cookieDecoder, - cookieEncoder, formDecoderProvider, forwardedHeaderHandler, httpMessageLogFactory, listener, + new Http2MultiplexHandler(new H2Codec(accessLogEnabled, accessLog, compressionOptions, compressPredicate, + http2SettingsSpec != null ? http2SettingsSpec.connectProtocolEnabled() : null, + cookieDecoder, cookieEncoder, formDecoderProvider, forwardedHeaderHandler, httpMessageLogFactory, listener, mapHandle, methodTagValue, metricsRecorder, minCompressionSize, opsFactory, readTimeout, requestTimeout, uriTagValue))); IdleTimeoutHandler.addIdleTimeoutHandler(p, idleTimeout); @@ -1007,6 +1024,7 @@ static final class H2Codec extends ChannelInitializer { final Function accessLog; final HttpCompressionOptionsSpec compressionOptions; final BiPredicate compressPredicate; + final Boolean connectProtocolEnabled; final ServerCookieDecoder cookieDecoder; final ServerCookieEncoder cookieEncoder; final HttpServerFormDecoderProvider formDecoderProvider; @@ -1028,6 +1046,7 @@ static final class H2Codec extends ChannelInitializer { @Nullable Function accessLog, @Nullable HttpCompressionOptionsSpec compressionOptions, @Nullable BiPredicate compressPredicate, + @Nullable Boolean connectProtocolEnabled, ServerCookieDecoder decoder, ServerCookieEncoder encoder, HttpServerFormDecoderProvider formDecoderProvider, @@ -1046,6 +1065,7 @@ static final class H2Codec extends ChannelInitializer { this.accessLog = accessLog; this.compressionOptions = compressionOptions; this.compressPredicate = compressPredicate; + this.connectProtocolEnabled = connectProtocolEnabled; this.cookieDecoder = decoder; this.cookieEncoder = encoder; this.formDecoderProvider = formDecoderProvider; @@ -1065,7 +1085,7 @@ static final class H2Codec extends ChannelInitializer { @Override protected void initChannel(Channel ch) { ch.pipeline().remove(this); - addStreamHandlers(ch, accessLogEnabled, accessLog, compressionOptions, compressPredicate, cookieDecoder, cookieEncoder, + addStreamHandlers(ch, accessLogEnabled, accessLog, compressionOptions, compressPredicate, connectProtocolEnabled, cookieDecoder, cookieEncoder, formDecoderProvider, forwardedHeaderHandler, httpMessageLogFactory, listener, mapHandle, methodTagValue, metricsRecorder, minCompressionSize, opsFactory, readTimeout, requestTimeout, uriTagValue); } @@ -1078,6 +1098,7 @@ static final class Http11OrH2CleartextCodec extends ChannelInitializer final Function accessLog; final HttpCompressionOptionsSpec compressionOptions; final BiPredicate compressPredicate; + final Boolean connectProtocolEnabled; final ServerCookieDecoder cookieDecoder; final ServerCookieEncoder cookieEncoder; final HttpServerFormDecoderProvider formDecoderProvider; @@ -1123,6 +1144,7 @@ static final class Http11OrH2CleartextCodec extends ChannelInitializer this.accessLog = accessLog; this.compressionOptions = compressionOptions; this.compressPredicate = compressPredicate; + this.connectProtocolEnabled = http2SettingsSpec != null ? http2SettingsSpec.connectProtocolEnabled() : null; this.cookieDecoder = cookieDecoder; this.cookieEncoder = cookieEncoder; this.formDecoderProvider = formDecoderProvider; @@ -1167,7 +1189,7 @@ static final class Http11OrH2CleartextCodec extends ChannelInitializer @Override protected void initChannel(Channel ch) { ch.pipeline().remove(this); - addStreamHandlers(ch, accessLogEnabled, accessLog, compressionOptions, compressPredicate, cookieDecoder, cookieEncoder, + addStreamHandlers(ch, accessLogEnabled, accessLog, compressionOptions, compressPredicate, connectProtocolEnabled, cookieDecoder, cookieEncoder, formDecoderProvider, forwardedHeaderHandler, httpMessageLogFactory, listener, mapHandle, methodTagValue, metricsRecorder, minCompressionSize, opsFactory, readTimeout, requestTimeout, uriTagValue); } @@ -1566,6 +1588,29 @@ else if (proxyProtocolSupportType == ProxyProtocolSupportType.AUTO) { } } + static final class ProtocolHeaderHandler extends ChannelInboundHandlerAdapter { + static final ProtocolHeaderHandler INSTANCE = new ProtocolHeaderHandler(); + static final String NAME = LEFT + "protocolHeaderHandler"; + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { + if (msg instanceof Http2HeadersFrame) { + Http2Headers headers = ((Http2HeadersFrame) msg).headers(); + CharSequence value = headers.get(Http2Headers.PseudoHeaderName.PROTOCOL.value()); + if (value != null) { + headers.set("x-protocol", value); + } + ctx.pipeline().remove(this); + } + ctx.fireChannelRead(msg); + } + + @Override + public boolean isSharable() { + return true; + } + } + static final class ReactorNettyHttpServerUpgradeHandler extends HttpServerUpgradeHandler { final Duration readTimeout; diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerOperations.java index 24831108c3..7f62d98268 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerOperations.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerOperations.java @@ -1240,6 +1240,7 @@ final Flux receiveFormInternal(HttpServerFormDecoderProvider config) { .doFinally(sig -> decoder.destroy()))); } + @SuppressWarnings("ReferenceEquality") final Mono withWebsocketSupport(String url, WebsocketServerSpec websocketServerSpec, BiFunction> websocketHandler) { @@ -1247,13 +1248,20 @@ final Mono withWebsocketSupport(String url, Objects.requireNonNull(websocketHandler, "websocketHandler"); if (markSentHeaders()) { isWebsocket = true; - WebsocketServerOperations ops = new WebsocketServerOperations(url, websocketServerSpec, this); + WebsocketServerOperations ops; + // ReferenceEquality is deliberate + if (version() == H2) { + ops = new Http2WebsocketServerOperations(url, websocketServerSpec, this); + } + else { + ops = new WebsocketServerOperations(url, websocketServerSpec, this); + } return FutureMono.from(ops.handshakerResult) .doOnEach(signal -> { if (!signal.hasError() && (websocketServerSpec.protocols() == null || ops.selectedSubprotocol() != null)) { websocketHandler.apply(ops, ops) - .subscribe(new WebsocketSubscriber(ops, Context.of(signal.getContextView()))); + .subscribe(ops.websocketSubscriber(signal.getContextView())); } }); } @@ -1265,11 +1273,17 @@ final Mono withWebsocketSupport(String url, static final class WebsocketSubscriber implements CoreSubscriber, ChannelFutureListener { final WebsocketServerOperations ops; - final Context context; + final Context context; + final ChannelFutureListener listener; WebsocketSubscriber(WebsocketServerOperations ops, Context context) { + this(ops, context, null); + } + + WebsocketSubscriber(WebsocketServerOperations ops, Context context, @Nullable ChannelFutureListener listener) { this.ops = ops; this.context = context; + this.listener = listener; } @Override @@ -1294,9 +1308,9 @@ public void operationComplete(ChannelFuture future) { @Override public void onComplete() { - if (ops.channel() - .isActive()) { - ops.sendCloseNow(new CloseWebSocketFrame(WebSocketCloseStatus.NORMAL_CLOSURE), this); + if (ops.channel().isActive()) { + ops.sendCloseNow(new CloseWebSocketFrame(WebSocketCloseStatus.NORMAL_CLOSURE), + listener == null ? this : listener); } } diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerRoutes.java b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerRoutes.java index af18d70211..a208e97a11 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerRoutes.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerRoutes.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011-2023 VMware, Inc. or its affiliates, All Rights Reserved. + * Copyright (c) 2011-2025 VMware, Inc. or its affiliates, All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -27,6 +27,7 @@ import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpHeaderValues; +import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.HttpMethod; import org.reactivestreams.Publisher; import reactor.netty.ByteBufFlux; @@ -341,9 +342,10 @@ default HttpServerRoutes ws(Predicate condition, BiFunction> handler, WebsocketServerSpec websocketServerSpec) { return route(condition, (req, resp) -> { - if (req.requestHeaders() - .containsValue(HttpHeaderNames.CONNECTION, HttpHeaderValues.UPGRADE, true)) { - HttpServerOperations ops = (HttpServerOperations) req; + HttpHeaders requestHeaders = req.requestHeaders(); + HttpServerOperations ops = (HttpServerOperations) req; + if (requestHeaders.containsValue(HttpHeaderNames.CONNECTION, HttpHeaderValues.UPGRADE, true) || + (ops.isHttp2 && requestHeaders.containsValue("x-protocol", HttpHeaderValues.WEBSOCKET, true))) { return ops.withWebsocketSupport(req.uri(), websocketServerSpec, handler); } return resp.sendNotFound(); diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/server/WebsocketServerOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/server/WebsocketServerOperations.java index e507cefdb1..1af4b16153 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/server/WebsocketServerOperations.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/server/WebsocketServerOperations.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011-2024 VMware, Inc. or its affiliates, All Rights Reserved. + * Copyright (c) 2011-2025 VMware, Inc. or its affiliates, All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -41,6 +41,7 @@ import io.netty.handler.codec.http.websocketx.extensions.compression.DeflateFrameServerExtensionHandshaker; import io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateServerExtensionHandshaker; import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; import reactor.core.publisher.Sinks; @@ -52,6 +53,8 @@ import reactor.netty.http.websocket.WebsocketInbound; import reactor.netty.http.websocket.WebsocketOutbound; import reactor.util.annotation.Nullable; +import reactor.util.context.Context; +import reactor.util.context.ContextView; import static io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateServerExtensionHandshaker.MAX_WINDOW_SIZE; import static reactor.netty.ReactorNetty.format; @@ -62,11 +65,11 @@ * @author Stephane Maldini * @author Simon Baslé */ -final class WebsocketServerOperations extends HttpServerOperations +class WebsocketServerOperations extends HttpServerOperations implements WebsocketInbound, WebsocketOutbound { - final WebSocketServerHandshaker handshaker; - final ChannelPromise handshakerResult; + WebSocketServerHandshaker handshakerHttp11; + ChannelPromise handshakerResult; final Sinks.One onCloseState; final boolean proxyPing; @@ -74,19 +77,23 @@ final class WebsocketServerOperations extends HttpServerOperations static final String INBOUND_CANCEL_LOG = "WebSocket server inbound receiver cancelled, closing Websocket."; - @SuppressWarnings("FutureReturnValueIgnored") WebsocketServerOperations(String wsUrl, WebsocketServerSpec websocketServerSpec, HttpServerOperations replaced) { super(replaced); this.proxyPing = websocketServerSpec.handlePing(); - Channel channel = replaced.channel(); onCloseState = Sinks.unsafe().one(); + initHandshaker(wsUrl, websocketServerSpec, replaced); + } + + @SuppressWarnings("FutureReturnValueIgnored") + void initHandshaker(String wsUrl, WebsocketServerSpec websocketServerSpec, HttpServerOperations replaced) { + Channel channel = replaced.channel(); // Handshake WebSocketServerHandshakerFactory wsFactory = new WebSocketServerHandshakerFactory(wsUrl, websocketServerSpec.protocols(), true, websocketServerSpec.maxFramePayloadLength()); - handshaker = wsFactory.newHandshaker(replaced.nettyRequest); - if (handshaker == null) { + handshakerHttp11 = wsFactory.newHandshaker(replaced.nettyRequest); + if (handshakerHttp11 == null) { //"FutureReturnValueIgnored" this is deliberate WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(channel); handshakerResult = null; @@ -141,7 +148,7 @@ final class WebsocketServerOperations extends HttpServerOperations } } - handshaker.handshake(channel, + handshakerHttp11.handshake(channel, request, replaced.responseHeaders .remove(HttpHeaderNames.TRANSFER_ENCODING), @@ -303,7 +310,11 @@ public boolean isWebsocket() { @Override @Nullable public String selectedSubprotocol() { - return handshaker.selectedSubprotocol(); + return handshakerHttp11.selectedSubprotocol(); + } + + Subscriber websocketSubscriber(ContextView contextView) { + return new WebsocketSubscriber(this, Context.of(contextView)); } static final AtomicIntegerFieldUpdater CLOSE_SENT = diff --git a/reactor-netty-http/src/main/resources/META-INF/native-image/io.projectreactor.netty/reactor-netty-http/reflect-config.json b/reactor-netty-http/src/main/resources/META-INF/native-image/io.projectreactor.netty/reactor-netty-http/reflect-config.json index d3775f1317..6fddd1a5c1 100644 --- a/reactor-netty-http/src/main/resources/META-INF/native-image/io.projectreactor.netty/reactor-netty-http/reflect-config.json +++ b/reactor-netty-http/src/main/resources/META-INF/native-image/io.projectreactor.netty/reactor-netty-http/reflect-config.json @@ -20,6 +20,27 @@ "name": "reactor.netty.http.client.Http2StreamBridgeClientHandler", "queryAllPublicMethods": true }, + { + "condition": { + "typeReachable": "reactor.netty.http.client.Http2WebsocketClientOperations$WebsocketClientHandshaker$ProtocolHeaderHandler" + }, + "name": "reactor.netty.http.client.Http2WebsocketClientOperations$WebsocketClientHandshaker$ProtocolHeaderHandler", + "queryAllPublicMethods": true + }, + { + "condition": { + "typeReachable": "reactor.netty.http.client.Http2WebsocketClientOperations$WebsocketClientHandshaker$WebsocketStreamBridgeClientHandler" + }, + "name": "reactor.netty.http.client.Http2WebsocketClientOperations$WebsocketClientHandshaker$WebsocketStreamBridgeClientHandler", + "queryAllPublicMethods": true + }, + { + "condition": { + "typeReachable": "reactor.netty.http.client.Http2WebsocketClientOperations$WebsocketClientExtensionHandler" + }, + "name": "reactor.netty.http.client.Http2WebsocketClientOperations$WebsocketClientExtensionHandler", + "queryAllPublicMethods": true + }, { "condition": { "typeReachable": "reactor.netty.http.client.Http3ChannelInitializer" @@ -139,6 +160,20 @@ "name": "reactor.netty.http.server.Http2StreamBridgeServerHandler", "queryAllPublicMethods": true }, + { + "condition": { + "typeReachable": "reactor.netty.http.server.Http2WebsocketServerOperations$WebsocketServerHandshaker$WebsocketStreamBridgeServerHandler" + }, + "name": "reactor.netty.http.server.Http2WebsocketServerOperations$WebsocketServerHandshaker$WebsocketStreamBridgeServerHandler", + "queryAllPublicMethods": true + }, + { + "condition": { + "typeReachable": "reactor.netty.http.server.Http2WebsocketServerOperations$WebsocketServerExtensionHandler" + }, + "name": "reactor.netty.http.server.Http2WebsocketServerOperations$WebsocketServerExtensionHandler", + "queryAllPublicMethods": true + }, { "condition": { "typeReachable": "reactor.netty.http.server.HttpServerConfig$H2ChannelMetricsHandler" @@ -195,6 +230,13 @@ "name": "reactor.netty.http.server.Http3StreamBridgeServerHandler", "queryAllPublicMethods": true }, + { + "condition": { + "typeReachable": "reactor.netty.http.server.HttpServerConfig$ProtocolHeaderHandler" + }, + "name": "reactor.netty.http.server.HttpServerConfig$ProtocolHeaderHandler", + "queryAllPublicMethods": true + }, { "condition": { "typeReachable": "reactor.netty.http.server.HttpServerConfig$ReactorNettyHttpServerUpgradeHandler" diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/Http2SettingsSpecTests.java b/reactor-netty-http/src/test/java/reactor/netty/http/Http2SettingsSpecTests.java index f5129a7220..af576a5e56 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/Http2SettingsSpecTests.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/Http2SettingsSpecTests.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020-2023 VMware, Inc. or its affiliates, All Rights Reserved. + * Copyright (c) 2020-2025 VMware, Inc. or its affiliates, All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -31,10 +31,25 @@ void setUp() { builder = Http2SettingsSpec.builder(); } + @Test + public void connectProtocolEnabled() { + builder.connectProtocolEnabled(true); + Http2SettingsSpec spec = builder.build(); + assertThat(spec.connectProtocolEnabled()).isTrue(); + assertThat(spec.headerTableSize()).isNull(); + assertThat(spec.initialWindowSize()).isNull(); + assertThat(spec.maxConcurrentStreams()).isNull(); + assertThat(spec.maxFrameSize()).isNull(); + assertThat(spec.maxHeaderListSize()).isEqualTo(Http2CodecUtil.DEFAULT_HEADER_LIST_SIZE); + assertThat(spec.maxStreams()).isNull(); + assertThat(spec.pushEnabled()).isNull(); + } + @Test void headerTableSize() { builder.headerTableSize(123); Http2SettingsSpec spec = builder.build(); + assertThat(spec.connectProtocolEnabled()).isNull(); assertThat(spec.headerTableSize()).isEqualTo(123); assertThat(spec.initialWindowSize()).isNull(); assertThat(spec.maxConcurrentStreams()).isNull(); @@ -55,6 +70,7 @@ void headerTableSizeBadValues() { void initialWindowSize() { builder.initialWindowSize(123); Http2SettingsSpec spec = builder.build(); + assertThat(spec.connectProtocolEnabled()).isNull(); assertThat(spec.headerTableSize()).isNull(); assertThat(spec.initialWindowSize()).isEqualTo(123); assertThat(spec.maxConcurrentStreams()).isNull(); @@ -75,6 +91,7 @@ void initialWindowSizeBadValues() { void maxConcurrentStreams() { builder.maxConcurrentStreams(123); Http2SettingsSpec spec = builder.build(); + assertThat(spec.connectProtocolEnabled()).isNull(); assertThat(spec.headerTableSize()).isNull(); assertThat(spec.initialWindowSize()).isNull(); assertThat(spec.maxConcurrentStreams()).isEqualTo(123); @@ -95,6 +112,7 @@ void maxConcurrentStreamsBadValues() { void maxFrameSize() { builder.maxFrameSize(16384); Http2SettingsSpec spec = builder.build(); + assertThat(spec.connectProtocolEnabled()).isNull(); assertThat(spec.headerTableSize()).isNull(); assertThat(spec.initialWindowSize()).isNull(); assertThat(spec.maxConcurrentStreams()).isNull(); @@ -115,6 +133,7 @@ void maxFrameSizeBadValues() { void maxHeaderListSize() { builder.maxHeaderListSize(123); Http2SettingsSpec spec = builder.build(); + assertThat(spec.connectProtocolEnabled()).isNull(); assertThat(spec.headerTableSize()).isNull(); assertThat(spec.initialWindowSize()).isNull(); assertThat(spec.maxConcurrentStreams()).isNull(); @@ -135,6 +154,7 @@ void maxHeaderListSizeBadValues() { public void maxStreamsNoMaxConcurrentStreams() { builder.maxStreams(123); Http2SettingsSpec spec = builder.build(); + assertThat(spec.connectProtocolEnabled()).isNull(); assertThat(spec.headerTableSize()).isNull(); assertThat(spec.initialWindowSize()).isNull(); assertThat(spec.maxConcurrentStreams()).isEqualTo(123); @@ -148,6 +168,7 @@ public void maxStreamsNoMaxConcurrentStreams() { public void maxStreamsWithMaxConcurrentStreams_1() { builder.maxStreams(123).maxConcurrentStreams(456); Http2SettingsSpec spec = builder.build(); + assertThat(spec.connectProtocolEnabled()).isNull(); assertThat(spec.headerTableSize()).isNull(); assertThat(spec.initialWindowSize()).isNull(); assertThat(spec.maxConcurrentStreams()).isEqualTo(123); @@ -161,6 +182,7 @@ public void maxStreamsWithMaxConcurrentStreams_1() { public void maxStreamsWithMaxConcurrentStreams_2() { builder.maxStreams(456).maxConcurrentStreams(123); Http2SettingsSpec spec = builder.build(); + assertThat(spec.connectProtocolEnabled()).isNull(); assertThat(spec.headerTableSize()).isNull(); assertThat(spec.initialWindowSize()).isNull(); assertThat(spec.maxConcurrentStreams()).isEqualTo(123); diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/Http11WebsocketTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/Http11WebsocketTest.java new file mode 100644 index 0000000000..dc09bcdb4d --- /dev/null +++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/Http11WebsocketTest.java @@ -0,0 +1,427 @@ +/* + * Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved. + * + * 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 + * + * https://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 reactor.netty.http.client; + +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.cookie.ClientCookieDecoder; +import io.netty.handler.codec.http.cookie.ClientCookieEncoder; +import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakeException; +import io.netty.handler.codec.http.websocketx.WebSocketFrame; +import org.junit.jupiter.api.Named; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.Connection; +import reactor.netty.ConnectionObserver; +import reactor.netty.http.HttpProtocol; +import reactor.netty.http.logging.ReactorNettyHttpMessageLogFactory; +import reactor.netty.http.server.HttpServer; +import reactor.netty.http.server.WebsocketServerSpec; +import reactor.netty.tcp.SslProvider; +import reactor.util.annotation.Nullable; + +import java.net.URI; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +class Http11WebsocketTest extends WebsocketTest { + + @Test + void simpleTest() { + doSimpleTest(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void serverWebSocketFailed() { + doServerWebSocketFailed(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void unidirectional() { + doUnidirectional(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void webSocketRespondsToRequestsFromClients() { + doWebSocketRespondsToRequestsFromClients(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void unidirectionalBinary() { + doUnidirectionalBinary(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void duplexEcho() throws Exception { + doDuplexEcho(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void simpleSubProtocolServerNoSubProtocol() { + doSimpleSubProtocolServerNoSubProtocol(createServer(), createClient(() -> disposableServer.address()), + "Invalid subprotocol. Actual: null. Expected one of: SUBPROTOCOL,OTHER"); + } + + @Test + void simpleSubProtocolServerNotSupported() { + doSimpleSubProtocolServerNotSupported(createServer(), createClient(() -> disposableServer.address()), + "Invalid subprotocol. Actual: null. Expected one of: SUBPROTOCOL,OTHER"); + } + + @Test + void simpleSubProtocolServerSupported() { + doSimpleSubProtocolServerSupported(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void simpleSubProtocolSelected() { + doSimpleSubProtocolSelected(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void noSubProtocolSelected() { + doNoSubProtocolSelected(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void anySubProtocolSelectsFirstClientProvided() { + doAnySubProtocolSelectsFirstClientProvided(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void sendToWebsocketSubProtocol() throws InterruptedException { + doSendToWebsocketSubProtocol(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testMaxFramePayloadLengthFailed() { + doTestMaxFramePayloadLengthFailed(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testMaxFramePayloadLengthSuccess() { + doTestMaxFramePayloadLengthSuccess(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testServerMaxFramePayloadLengthFailed() { + doTestServerMaxFramePayloadLength(createServer(), createClient(() -> disposableServer.address()), 10, + Flux.just("1", "2", "12345678901", "3"), Flux.just("1", "2"), 2); + } + + @Test + void testServerMaxFramePayloadLengthSuccess() { + doTestServerMaxFramePayloadLength(createServer(), createClient(() -> disposableServer.address()), 11, + Flux.just("1", "2", "12345678901", "3"), Flux.just("1", "2", "12345678901", "3"), 4); + } + + @Test + void closePool() { + doClosePool(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testCloseWebSocketFrameSentByServer() { + doTestCloseWebSocketFrameSentByServer(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testCloseWebSocketFrameSentByClient() { + doTestCloseWebSocketFrameSentByClient(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testConnectionAliveWhenTransformationErrors_1() { + doTestConnectionAliveWhenTransformationErrors(createServer(), createClient(() -> disposableServer.address()), (in, out) -> + out.sendObject(in.aggregateFrames() + .receiveFrames() + .map(WebSocketFrame::content) + //.share() + .publish() + .autoConnect() + .map(byteBuf -> + byteBuf.readCharSequence(byteBuf.readableBytes(), Charset.defaultCharset()).toString()) + .map(Integer::parseInt) + .map(i -> new TextWebSocketFrame(i + "")) + .retry()), + Flux.just("1", "2"), 2); + } + + @Test + void testConnectionAliveWhenTransformationErrors_2() { + doTestConnectionAliveWhenTransformationErrors(createServer(), createClient(() -> disposableServer.address()), (in, out) -> + out.sendObject(in.aggregateFrames() + .receiveFrames() + .map(WebSocketFrame::content) + .concatMap(content -> + Mono.just(content) + .map(byteBuf -> + byteBuf.readCharSequence(byteBuf.readableBytes(), Charset.defaultCharset()).toString()) + .map(Integer::parseInt) + .map(i -> new TextWebSocketFrame(i + "")) + .onErrorResume(t -> Mono.just(new TextWebSocketFrame("error"))))), + Flux.just("1", "error", "2"), 3); + } + + @Test + void testClientOnCloseIsInvokedClientSendClose() throws Exception { + doTestClientOnCloseIsInvokedClientSendClose(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testClientOnCloseIsInvokedClientDisposed() throws Exception { + doTestClientOnCloseIsInvokedClientDisposed(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testClientOnCloseIsInvokedServerInitiatedClose() throws Exception { + doTestClientOnCloseIsInvokedServerInitiatedClose(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testIssue460() { + doTestIssue460(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testIssue444_1() { + doTestIssue444(createServer(), createClient(() -> disposableServer.address()), (in, out) -> + out.sendObject(Flux.error(new Throwable()) + .onErrorResume(ex -> out.sendClose(1001, "Going Away")) + .cast(WebSocketFrame.class))); + } + + @Test + void testIssue444_2() { + doTestIssue444(createServer(), createClient(() -> disposableServer.address()), (in, out) -> + out.send(Flux.range(0, 10) + .map(i -> { + if (i == 5) { + out.sendClose(1001, "Going Away").subscribe(); + } + return Unpooled.copiedBuffer((i + "").getBytes(Charset.defaultCharset())); + }))); + } + + @Test + void testIssue444_3() { + doTestIssue444(createServer(), createClient(() -> disposableServer.address()), (in, out) -> + out.sendObject(Flux.error(new Throwable()) + .onErrorResume(ex -> Flux.empty()) + .cast(WebSocketFrame.class)) + .then(Mono.defer(() -> out.sendObject(new CloseWebSocketFrame(1001, "Going Away")).then()))); + } + + // https://bugzilla.mozilla.org/show_bug.cgi?id=691300 + @Test + void firefoxConnectionTest() { + disposableServer = + createServer() + .route(r -> r.ws("/ws", (in, out) -> out.sendString(Mono.just("test")))) + .bindNow(); + + HttpClientResponse res = + createClient(disposableServer.port()) + .headers(h -> { + h.add(HttpHeaderNames.CONNECTION, "keep-alive, Upgrade"); + h.add(HttpHeaderNames.UPGRADE, "websocket"); + h.add(HttpHeaderNames.ORIGIN, "http://localhost"); + }) + .get() + .uri("/ws") + .response() + .block(Duration.ofSeconds(5)); + assertThat(res).isNotNull(); + assertThat(res.status()).isEqualTo(HttpResponseStatus.SWITCHING_PROTOCOLS); + } + + @Test + void testIssue821() throws Exception { + doTestIssue821(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testIssue900_1() throws Exception { + doTestIssue900_1(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testIssue900_2() throws Exception { + doTestIssue900_2(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testIssue663_1() throws Exception { + doTestIssue663_1(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testIssue663_2() throws Exception { + doTestIssue663_2(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testIssue663_3() throws Exception { + doTestIssue663_3(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testIssue663_4() throws Exception { + doTestIssue663_4(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testIssue967() throws Exception { + doTestIssue967(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testIssue970_WithCompress() { + doTestWebsocketCompression(createServer(), createClient(() -> disposableServer.address()), true); + } + + @Test + void testIssue970_NoCompress() { + doTestWebsocketCompression(createServer(), createClient(() -> disposableServer.address()), false); + } + + @Test + void testIssue2973() { + doTestWebsocketCompression(createServer(), createClient(() -> disposableServer.address()), true, true); + } + + @Test + void websocketOperationsBadValues() throws Exception { + EmbeddedChannel channel = new EmbeddedChannel(); + HttpClientOperations parent = new HttpClientOperations(Connection.from(channel), + ConnectionObserver.emptyListener(), ClientCookieEncoder.STRICT, ClientCookieDecoder.STRICT, + ReactorNettyHttpMessageLogFactory.INSTANCE); + WebsocketClientOperations ops = new WebsocketClientOperations(new URI(""), + WebsocketClientSpec.builder().build(), parent); + + assertThatExceptionOfType(IllegalArgumentException.class) + .isThrownBy(() -> ops.aggregateFrames(-1)) + .withMessageEndingWith("-1 (expected: >= 0)"); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> ops.send(null)); + + assertThatExceptionOfType(NullPointerException.class) + .isThrownBy(() -> ops.sendString(null, Charset.defaultCharset())); + } + + @Test + void testIssue1485_CloseFrameSentByClient() throws Exception { + doTestIssue1485_CloseFrameSentByClient(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testIssue1485_CloseFrameSentByServer() throws Exception { + doTestIssue1485_CloseFrameSentByServer(createServer(), createClient(() -> disposableServer.address())); + } + + @Test + void testConnectionClosedWhenFailedUpgrade_NoErrorHandling() throws Exception { + doTestConnectionClosedWhenFailedUpgrade(createServer(), createClient(() -> disposableServer.address()), null); + } + + @Test + void testConnectionClosedWhenFailedUpgrade_ClientErrorHandling() throws Exception { + AtomicReference error = new AtomicReference<>(); + doTestConnectionClosedWhenFailedUpgrade(createServer(), + createClient(() -> disposableServer.address()).doOnRequestError((req, t) -> error.set(t)), null); + assertThat(error.get()).isNotNull() + .isInstanceOf(WebSocketClientHandshakeException.class); + assertThat(((WebSocketClientHandshakeException) error.get()).response().status()) + .isEqualTo(HttpResponseStatus.NOT_FOUND); + } + + @Test + void testConnectionClosedWhenFailedUpgrade_PublisherErrorHandling() throws Exception { + AtomicReference error = new AtomicReference<>(); + doTestConnectionClosedWhenFailedUpgrade(createServer(), createClient(() -> disposableServer.address()), error::set); + assertThat(error.get()).isNotNull() + .isInstanceOf(WebSocketClientHandshakeException.class); + assertThat(((WebSocketClientHandshakeException) error.get()).response().status()) + .isEqualTo(HttpResponseStatus.NOT_FOUND); + } + + @ParameterizedTest + @MethodSource("http11CompatibleProtocols") + @SuppressWarnings("deprecation") + public void testIssue3036(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable SslProvider.ProtocolSslContextSpec serverCtx, @Nullable SslProvider.ProtocolSslContextSpec clientCtx) { + WebsocketServerSpec websocketServerSpec = WebsocketServerSpec.builder().compress(true).build(); + + HttpServer httpServer = createServer().protocol(serverProtocols); + if (serverCtx != null) { + httpServer = httpServer.secure(spec -> spec.sslContext(serverCtx)); + } + + disposableServer = + httpServer.handle((req, res) -> res.sendWebsocket((in, out) -> out.sendString(Mono.just("test")), websocketServerSpec)) + .bindNow(); + + WebsocketClientSpec webSocketClientSpec = WebsocketClientSpec.builder().compress(true).build(); + + HttpClient httpClient = createClient(disposableServer::address).protocol(clientProtocols); + if (clientCtx != null) { + httpClient = httpClient.secure(spec -> spec.sslContext(clientCtx)); + } + + AtomicReference> responseHeaders = new AtomicReference<>(new ArrayList<>()); + httpClient.websocket(webSocketClientSpec) + .handle((in, out) -> { + responseHeaders.set(in.headers().getAll(HttpHeaderNames.SEC_WEBSOCKET_EXTENSIONS)); + return out.sendClose(); + }) + .then() + .block(Duration.ofSeconds(5)); + + assertThat(responseHeaders.get()).contains("permessage-deflate"); + } + + @Test + void testIssue3295() throws Exception { + doTestIssue3295(createServer(), createClient(() -> disposableServer.address())); + } + + static Stream http11CompatibleProtocols() { + return Stream.of( + Arguments.of(new HttpProtocol[]{HttpProtocol.HTTP11}, new HttpProtocol[]{HttpProtocol.HTTP11}, null, null), + Arguments.of(new HttpProtocol[]{HttpProtocol.HTTP11}, new HttpProtocol[]{HttpProtocol.HTTP11}, + Named.of("Http11SslContextSpec", serverCtx11), Named.of("Http11SslContextSpec", clientCtx11)), + Arguments.of(new HttpProtocol[]{HttpProtocol.H2, HttpProtocol.HTTP11}, new HttpProtocol[]{HttpProtocol.HTTP11}, + Named.of("Http2SslContextSpec", serverCtx2), Named.of("Http11SslContextSpec", clientCtx11)), + Arguments.of(new HttpProtocol[]{HttpProtocol.H2C, HttpProtocol.HTTP11}, new HttpProtocol[]{HttpProtocol.HTTP11}, null, null) + ); + } +} diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/Http2WebsocketTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/Http2WebsocketTest.java new file mode 100644 index 0000000000..9e063ac155 --- /dev/null +++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/Http2WebsocketTest.java @@ -0,0 +1,667 @@ +/* + * Copyright (c) 2025 VMware, Inc. or its affiliates, All Rights Reserved. + * + * 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 + * + * https://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 reactor.netty.http.client; + +import io.netty.buffer.Unpooled; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.channel.ChannelOutboundHandlerAdapter; +import io.netty.channel.ChannelPromise; +import io.netty.handler.codec.http.HttpHeaderNames; +import io.netty.handler.codec.http.HttpMethod; +import io.netty.handler.codec.http.HttpRequest; +import io.netty.handler.codec.http.HttpResponseStatus; +import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame; +import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; +import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakeException; +import io.netty.handler.codec.http.websocketx.WebSocketFrame; +import io.netty.handler.codec.http.websocketx.WebSocketVersion; +import io.netty.handler.codec.http2.DefaultHttp2HeadersFrame; +import io.netty.handler.codec.http2.Http2Headers; +import io.netty.handler.codec.http2.Http2HeadersFrame; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Named; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.netty.http.Http2SslContextSpec; +import reactor.netty.http.HttpProtocol; +import reactor.netty.http.server.HttpServer; +import reactor.netty.resources.ConnectionProvider; +import reactor.test.StepVerifier; +import reactor.util.annotation.Nullable; + +import java.net.SocketAddress; +import java.nio.charset.Charset; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class Http2WebsocketTest extends WebsocketTest { + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2SimpleTest(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doSimpleTest(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2ServerFailed(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doServerWebSocketFailed(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2Unidirectional(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doUnidirectional(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2ServerRespondsToRequestsFromClients(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doWebSocketRespondsToRequestsFromClients(configureServer(serverProtocols, serverCtx), + configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2UnidirectionalBinary(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doUnidirectionalBinary(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2DuplexEcho(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + doDuplexEcho(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2SimpleSubProtocolServerNoSubProtocol(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doSimpleSubProtocolServerNoSubProtocol(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx), + "Invalid subprotocol. Actual [null]. Expected one of [SUBPROTOCOL,OTHER]"); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2SimpleSubProtocolServerNotSupported(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doSimpleSubProtocolServerNotSupported(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx), + "Invalid subprotocol. Actual [null]. Expected one of [SUBPROTOCOL,OTHER]"); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2SimpleSubProtocolServerSupported(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doSimpleSubProtocolServerSupported(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2simpleSubProtocolSelected(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doSimpleSubProtocolSelected(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2NoSubProtocolSelected(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doNoSubProtocolSelected(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2AnySubProtocolSelectsFirstClientProvided(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doAnySubProtocolSelectsFirstClientProvided(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2SendToWebsocketSubProtocol(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws InterruptedException { + doSendToWebsocketSubProtocol(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestMaxFramePayloadLengthFailed(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doTestMaxFramePayloadLengthFailed(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestMaxFramePayloadLengthSuccess(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doTestMaxFramePayloadLengthSuccess(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestServerMaxFramePayloadLengthFailed(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doTestServerMaxFramePayloadLength(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx), 10, + Flux.just("1", "2", "12345678901", "3"), Flux.just("1", "2"), 2); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestServerMaxFramePayloadLengthSuccess(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doTestServerMaxFramePayloadLength(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx), 11, + Flux.just("1", "2", "12345678901", "3"), Flux.just("1", "2", "12345678901", "3"), 4); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2ClosePool(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doClosePool(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestCloseWebSocketFrameSentByServer(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doTestCloseWebSocketFrameSentByServer(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestCloseWebSocketFrameSentByClient(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doTestCloseWebSocketFrameSentByClient(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestStreamAliveWhenTransformationErrors_1(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doTestConnectionAliveWhenTransformationErrors(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx), + (in, out) -> out.sendObject(in.aggregateFrames() + .receiveFrames() + .map(WebSocketFrame::content) + //.share() + .publish() + .autoConnect() + .map(byteBuf -> byteBuf.readCharSequence(byteBuf.readableBytes(), Charset.defaultCharset()).toString()) + .map(Integer::parseInt) + .map(i -> new TextWebSocketFrame(i + "")) + .retry()), + Flux.just("1", "2"), 2); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestStreamAliveWhenTransformationErrors_2(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doTestConnectionAliveWhenTransformationErrors(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx), + (in, out) -> out.sendObject(in.aggregateFrames() + .receiveFrames() + .map(WebSocketFrame::content) + .concatMap(content -> + Mono.just(content) + .map(byteBuf -> byteBuf.readCharSequence(byteBuf.readableBytes(), Charset.defaultCharset()).toString()) + .map(Integer::parseInt) + .map(i -> new TextWebSocketFrame(i + "")) + .onErrorResume(t -> Mono.just(new TextWebSocketFrame("error"))))), + Flux.just("1", "error", "2"), 3); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestClientOnCloseIsInvokedClientSendClose(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + doTestClientOnCloseIsInvokedClientSendClose(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestClientOnCloseIsInvokedClientDisposed(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + doTestClientOnCloseIsInvokedClientDisposed(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestClientOnCloseIsInvokedServerInitiatedClose(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + doTestClientOnCloseIsInvokedServerInitiatedClose(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestIssue460(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doTestIssue460(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestIssue444_1(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doTestIssue444(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx), + (in, out) -> out.sendObject(Flux.error(new Throwable()) + .onErrorResume(ex -> out.sendClose(1001, "Going Away")) + .cast(WebSocketFrame.class))); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestIssue444_2(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doTestIssue444(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx), + (in, out) -> out.send(Flux.range(0, 10) + .map(i -> { + if (i == 5) { + out.sendClose(1001, "Going Away").subscribe(); + } + return Unpooled.copiedBuffer((i + "").getBytes(Charset.defaultCharset())); + }))); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestIssue444_3(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doTestIssue444(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx), + (in, out) -> out.sendObject(Flux.error(new Throwable()) + .onErrorResume(ex -> Flux.empty()) + .cast(WebSocketFrame.class)) + .then(Mono.defer(() -> out.sendObject(new CloseWebSocketFrame(1001, "Going Away")).then()))); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + @Disabled + void websocketOverH2TestIssue821(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + doTestIssue821(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestIssue900_1(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + doTestIssue900_1(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestIssue900_2(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + doTestIssue900_2(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestIssue663_1(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + doTestIssue663_1(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestIssue663_2(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + doTestIssue663_2(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestIssue663_3(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + doTestIssue663_3(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestIssue663_4(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + doTestIssue663_4(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestIssue967(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + doTestIssue967(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestIssue970_WithCompress(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doTestWebsocketCompression(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx), true); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestIssue970_NoCompress(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doTestWebsocketCompression(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx), false); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestIssue2973(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) { + doTestWebsocketCompression(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx), true, true); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestIssue1485_CloseFrameSentByClient(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + doTestIssue1485_CloseFrameSentByClient(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestIssue1485_CloseFrameSentByServer(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + doTestIssue1485_CloseFrameSentByServer(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestConnectionClosedWhenFailedUpgrade_NoErrorHandling(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + doTestConnectionClosedWhenFailedUpgrade(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx), null); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestConnectionClosedWhenFailedUpgrade_ClientErrorHandling(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + AtomicReference error = new AtomicReference<>(); + doTestConnectionClosedWhenFailedUpgrade(configureServer(serverProtocols, serverCtx), + configureClient(clientProtocols, clientCtx).doOnRequestError((req, t) -> error.set(t)), null); + assertThat(error.get()).isNotNull() + .isInstanceOf(WebSocketClientHandshakeException.class); + assertThat(((WebSocketClientHandshakeException) error.get()).response().status()) + .isEqualTo(HttpResponseStatus.NOT_FOUND); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestConnectionClosedWhenFailedUpgrade_PublisherErrorHandling(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + AtomicReference error = new AtomicReference<>(); + doTestConnectionClosedWhenFailedUpgrade(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx), error::set); + assertThat(error.get()).isNotNull() + .isInstanceOf(WebSocketClientHandshakeException.class); + assertThat(((WebSocketClientHandshakeException) error.get()).response().status()) + .isEqualTo(HttpResponseStatus.NOT_FOUND); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2TestIssue3295(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + doTestIssue3295(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + @SuppressWarnings("deprecation") + void websocketOverH2NotSupportedByServerExplicit(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + TestHttpMeterRegistrarAdapter metricsRegistrar = new TestHttpMeterRegistrarAdapter(); + + ConnectionProvider provider = + ConnectionProvider.builder("clientSendsError") + .maxConnections(10) + .metrics(true, () -> metricsRegistrar) + .build(); + try { + HttpServer server = serverCtx == null ? createServer().protocol(serverProtocols) : + createServer().protocol(serverProtocols).secure(spec -> spec.sslContext(serverCtx)); + websocketOverH2Negative(server.http2Settings(spec -> spec.connectProtocolEnabled(false)), + configureClient(provider, clientProtocols, clientCtx), + t -> ("Websocket is not supported by the server. " + + "[SETTINGS_ENABLE_CONNECT_PROTOCOL(0x8)=0] was received.").equals(t.getMessage())); + + HttpConnectionPoolMetrics metrics = metricsRegistrar.metrics; + assertThat(metrics).isNotNull(); + assertThat(metrics.activeStreamSize()).isEqualTo(0); + } + finally { + provider.disposeLater() + .block(Duration.ofSeconds(5)); + } + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + @SuppressWarnings("deprecation") + void websocketOverH2NotSupportedByServerImplicit(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + TestHttpMeterRegistrarAdapter metricsRegistrar = new TestHttpMeterRegistrarAdapter(); + + ConnectionProvider provider = + ConnectionProvider.builder("clientSendsError") + .maxConnections(10) + .metrics(true, () -> metricsRegistrar) + .build(); + try { + HttpServer server = serverCtx == null ? createServer().protocol(serverProtocols) : + createServer().protocol(serverProtocols).secure(spec -> spec.sslContext(serverCtx)); + websocketOverH2Negative(server, configureClient(provider, clientProtocols, clientCtx), + t -> ("Websocket is not supported by the server. " + + "Missing SETTINGS_ENABLE_CONNECT_PROTOCOL(0x8).").equals(t.getMessage())); + + HttpConnectionPoolMetrics metrics = metricsRegistrar.metrics; + assertThat(metrics).isNotNull(); + assertThat(metrics.activeStreamSize()).isEqualTo(0); + } + finally { + provider.disposeLater() + .block(Duration.ofSeconds(5)); + } + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2VersionDifferentThan13OnClient(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + websocketOverH2Negative(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx), + WebsocketClientSpec.builder().version(WebSocketVersion.V07).build(), + t -> "Websocket version [7] is not supported.".equals(t.getMessage()), null); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2VersionDifferentThan13ReceivedOnServer(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + HttpServer server = configureServer(serverProtocols, serverCtx); + server = server.doOnConnection(conn -> + conn.addHandlerLast("test", new ChannelInboundHandlerAdapter() { + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + if (msg instanceof HttpRequest) { + ((HttpRequest) msg).headers().set(HttpHeaderNames.SEC_WEBSOCKET_VERSION, + WebSocketVersion.V07.toHttpHeaderValue()); + ctx.pipeline().remove(this); + } + ctx.fireChannelRead(msg); + } + })); + + websocketOverH2Negative(server, configureClient(clientProtocols, clientCtx), + t -> "Invalid websocket handshake response status [400 Bad Request].".equals(t.getMessage()), + "Websocket version [7] is not supported."); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2MethodDifferentThanConnectReceivedOnServer(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + websocketOverH2NegativeServer(serverProtocols, clientProtocols, serverCtx, clientCtx, + h -> { + Http2HeadersFrame newHeaders = new DefaultHttp2HeadersFrame(h.headers()); + newHeaders.headers().set(Http2Headers.PseudoHeaderName.METHOD.value(), HttpMethod.GET.name()); + return newHeaders; + }, + "Invalid websocket request handshake method [GET]."); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2MissingProtocolHeaderReceivedOnServer(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + websocketOverH2NegativeServer(serverProtocols, clientProtocols, serverCtx, clientCtx, + h -> { + Http2HeadersFrame newHeaders = new DefaultHttp2HeadersFrame(h.headers()); + newHeaders.headers().remove(Http2Headers.PseudoHeaderName.PROTOCOL.value()); + return newHeaders; + }, + "Invalid websocket request, missing [:protocol=websocket] header."); + } + + @ParameterizedTest + @MethodSource("http2CompatibleCombinations") + void websocketOverH2EndOfStreamReceivedOnServer(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { + websocketOverH2NegativeServer(serverProtocols, clientProtocols, serverCtx, clientCtx, + h -> new DefaultHttp2HeadersFrame(h.headers(), true), + "Failed to upgrade to websocket. End of stream is received."); + } + + void websocketOverH2NegativeServer(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, + @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx, + Function headers, String serverError) throws Exception { + HttpClient client = configureClient(clientProtocols, clientCtx); + client = client.doOnRequest((req, conn) -> + conn.channel().pipeline().addFirst("websocketOverH2NegativeServer", new ChannelOutboundHandlerAdapter() { + + @Override + @SuppressWarnings("FutureReturnValueIgnored") + public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise) { + if (msg instanceof Http2HeadersFrame) { + //"FutureReturnValueIgnored" this is deliberate + ctx.write(headers.apply((Http2HeadersFrame) msg), promise); + } + else { + //"FutureReturnValueIgnored" this is deliberate + ctx.write(msg, promise); + } + } + })); + + websocketOverH2Negative(configureServer(serverProtocols, serverCtx), client, + t -> "Invalid websocket handshake response status [400 Bad Request].".equals(t.getMessage()), + serverError); + } + + void websocketOverH2Negative(HttpServer server, HttpClient client, Predicate predicate) throws Exception { + websocketOverH2Negative(server, client, WebsocketClientSpec.builder().build(), predicate, null); + } + + void websocketOverH2Negative(HttpServer server, HttpClient client, Predicate predicate, + String serverError) throws Exception { + websocketOverH2Negative(server, client, WebsocketClientSpec.builder().build(), predicate, serverError); + } + + void websocketOverH2Negative(HttpServer server, HttpClient client, + WebsocketClientSpec clientSpec, Predicate predicate, @Nullable String serverError) throws Exception { + AtomicReference serverThrowable = new AtomicReference<>(); + disposableServer = + server.handle((req, res) -> res.sendWebsocket((in, out) -> out.sendString(Mono.just("test"))) + .doOnError(serverThrowable::set)) + .bindNow(); + + CountDownLatch connClosed = new CountDownLatch(1); + client.doOnRequest((req, conn) -> conn.channel().closeFuture().addListener(f -> connClosed.countDown())) + .websocket(clientSpec) + .uri("/test") + .handle((i, o) -> i.receive().asString()) + .collectList() + .as(StepVerifier::create) + .expectErrorMatches(predicate) + .verify(Duration.ofSeconds(5)); + + assertThat(connClosed.await(5, TimeUnit.SECONDS)).isTrue(); + + if (serverError != null) { + assertThat(serverThrowable.get()) + .isNotNull() + .hasMessage(serverError); + } + } + + @SuppressWarnings("deprecation") + HttpClient configureClient(HttpProtocol[] clientProtocols, @Nullable Http2SslContextSpec clientCtx) { + return clientCtx == null ? createClient(() -> disposableServer.address()).protocol(clientProtocols) : + createClient(() -> disposableServer.address()).protocol(clientProtocols).secure(spec -> spec.sslContext(clientCtx)); + } + + @SuppressWarnings("deprecation") + HttpClient configureClient(ConnectionProvider provider, HttpProtocol[] clientProtocols, @Nullable Http2SslContextSpec clientCtx) { + return clientCtx == null ? createClient(provider, () -> disposableServer.address()).protocol(clientProtocols) : + createClient(provider, () -> disposableServer.address()).protocol(clientProtocols).secure(spec -> spec.sslContext(clientCtx)); + } + + @SuppressWarnings("deprecation") + static HttpServer configureServer(HttpProtocol[] serverProtocols, @Nullable Http2SslContextSpec serverCtx) { + HttpServer server = serverCtx == null ? createServer() : createServer().secure(spec -> spec.sslContext(serverCtx)); + return server.protocol(serverProtocols).http2Settings(spec -> spec.connectProtocolEnabled(true)); + } + + static Stream http2CompatibleCombinations() { + return Stream.of( + Arguments.of(new HttpProtocol[]{HttpProtocol.H2}, new HttpProtocol[]{HttpProtocol.H2}, + Named.of("Http2SslContextSpec", serverCtx2), Named.of("Http2SslContextSpec", clientCtx2)), + Arguments.of(new HttpProtocol[]{HttpProtocol.H2}, new HttpProtocol[]{HttpProtocol.H2, HttpProtocol.HTTP11}, + Named.of("Http2SslContextSpec", serverCtx2), Named.of("Http2SslContextSpec", clientCtx2)), + Arguments.of(new HttpProtocol[]{HttpProtocol.H2, HttpProtocol.HTTP11}, new HttpProtocol[]{HttpProtocol.H2}, + Named.of("Http2SslContextSpec", serverCtx2), Named.of("Http2SslContextSpec", clientCtx2)), + Arguments.of(new HttpProtocol[]{HttpProtocol.H2, HttpProtocol.HTTP11}, new HttpProtocol[]{HttpProtocol.H2, HttpProtocol.HTTP11}, + Named.of("Http2SslContextSpec", serverCtx2), Named.of("Http2SslContextSpec", clientCtx2)), + Arguments.of(new HttpProtocol[]{HttpProtocol.H2C}, new HttpProtocol[]{HttpProtocol.H2C}, null, null), + Arguments.of(new HttpProtocol[]{HttpProtocol.H2C, HttpProtocol.HTTP11}, new HttpProtocol[]{HttpProtocol.H2C}, null, null) + ); + } + + static final class TestHttpMeterRegistrarAdapter extends HttpMeterRegistrarAdapter { + HttpConnectionPoolMetrics metrics; + + @Override + protected void registerMetrics(String poolName, String id, SocketAddress remoteAddress, HttpConnectionPoolMetrics metrics) { + this.metrics = metrics; + } + } +} diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/WebsocketTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/WebsocketTest.java index f205eb9a81..bb1ade2ab1 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/client/WebsocketTest.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/WebsocketTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2011-2024 VMware, Inc. or its affiliates, All Rights Reserved. + * Copyright (c) 2011-2025 VMware, Inc. or its affiliates, All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,7 +15,6 @@ */ package reactor.netty.http.client; -import java.net.URI; import java.nio.charset.Charset; import java.security.cert.CertificateException; import java.time.Duration; @@ -28,26 +27,19 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiFunction; import java.util.function.Consumer; -import java.util.function.Function; import java.util.function.Predicate; -import java.util.stream.Stream; import io.netty.buffer.DefaultByteBufHolder; import io.netty.buffer.Unpooled; -import io.netty.channel.embedded.EmbeddedChannel; import io.netty.handler.codec.CorruptedFrameException; import io.netty.handler.codec.TooLongFrameException; import io.netty.handler.codec.http.HttpHeaderNames; -import io.netty.handler.codec.http.HttpResponseStatus; -import io.netty.handler.codec.http.cookie.ClientCookieDecoder; -import io.netty.handler.codec.http.cookie.ClientCookieEncoder; import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame; import io.netty.handler.codec.http.websocketx.ContinuationWebSocketFrame; import io.netty.handler.codec.http.websocketx.PingWebSocketFrame; import io.netty.handler.codec.http.websocketx.PongWebSocketFrame; import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; -import io.netty.handler.codec.http.websocketx.WebSocketClientHandshakeException; import io.netty.handler.codec.http.websocketx.WebSocketCloseStatus; import io.netty.handler.codec.http.websocketx.WebSocketFrame; import io.netty.handler.codec.http.websocketx.WebSocketHandshakeException; @@ -55,11 +47,6 @@ import io.netty.handler.ssl.util.SelfSignedCertificate; import io.netty.util.CharsetUtil; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Named; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.MethodSource; import org.reactivestreams.Publisher; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -67,18 +54,14 @@ import reactor.core.scheduler.Schedulers; import reactor.netty.BaseHttpTest; import reactor.netty.Connection; -import reactor.netty.ConnectionObserver; import reactor.netty.channel.AbortedException; import reactor.netty.http.Http11SslContextSpec; import reactor.netty.http.Http2SslContextSpec; -import reactor.netty.http.HttpProtocol; -import reactor.netty.http.logging.ReactorNettyHttpMessageLogFactory; import reactor.netty.http.server.HttpServer; import reactor.netty.http.server.WebsocketServerSpec; import reactor.netty.http.websocket.WebsocketInbound; import reactor.netty.http.websocket.WebsocketOutbound; import reactor.netty.resources.ConnectionProvider; -import reactor.netty.tcp.SslProvider; import reactor.test.StepVerifier; import reactor.util.Logger; import reactor.util.Loggers; @@ -87,7 +70,6 @@ import static io.netty.handler.codec.http.websocketx.WebSocketCloseStatus.ABNORMAL_CLOSURE; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; /** * This test class verifies {@link HttpClient} websocket functionality. @@ -118,69 +100,60 @@ static void createSelfSignedCertificate() throws CertificateException { .configure(builder -> builder.trustManager(InsecureTrustManagerFactory.INSTANCE)); } - @Test - void simpleTest() { - disposableServer = createServer() - .handle((in, out) -> out.sendWebsocket((i, o) -> o.sendString(Mono.just("test")))) - .bindNow(); + void doSimpleTest(HttpServer server, HttpClient client) { + disposableServer = server.handle((in, out) -> out.sendWebsocket((i, o) -> o.sendString(Mono.just("test")))) + .bindNow(); List res = - createClient(disposableServer.port()) - .headers(h -> h.add("Authorization", auth)) - .websocket() - .uri("/test") - .handle((i, o) -> i.receive().asString()) - .log("client") - .collectList() - .block(Duration.ofSeconds(5)); + client.headers(h -> h.add("Authorization", auth)) + .websocket() + .uri("/test") + .handle((i, o) -> i.receive().asString()) + .log("client") + .collectList() + .block(Duration.ofSeconds(5)); assertThat(res).isNotNull(); assertThat(res.get(0)).isEqualTo("test"); } - @Test - void serverWebSocketFailed() { + void doServerWebSocketFailed(HttpServer server, HttpClient client) { disposableServer = - createServer() - .handle((in, out) -> { - if (!in.requestHeaders().contains("Authorization")) { - return out.status(401); - } - else { - return out.sendWebsocket((i, o) -> o.sendString(Mono.just("test"))); - } - }) - .bindNow(); + server.handle((in, out) -> { + if (!in.requestHeaders().contains("Authorization")) { + return out.status(401); + } + else { + return out.sendWebsocket((i, o) -> o.sendString(Mono.just("test"))); + } + }) + .bindNow(); Mono res = - createClient(disposableServer.port()) - .websocket() - .uri("/test") - .handle((in, out) -> in.receive().aggregate().asString()) - .next(); + client.websocket() + .uri("/test") + .handle((in, out) -> in.receive().aggregate().asString()) + .next(); StepVerifier.create(res) .expectError(WebSocketHandshakeException.class) .verify(Duration.ofSeconds(30)); } - @Test - void unidirectional() { + void doUnidirectional(HttpServer server, HttpClient client) { int c = 10; - disposableServer = createServer() - .handle((in, out) -> out.sendWebsocket( - (i, o) -> o.sendString( - Mono.just("test") - .delayElement(Duration.ofMillis(100)) - .repeat()))) - .bindNow(); - - Flux ws = createClient(disposableServer.port()) - .websocket() - .uri("/") - .handle((in, out) -> in.aggregateFrames() - .receive() - .asString()); + disposableServer = server.handle((in, out) -> out.sendWebsocket( + (i, o) -> o.sendString( + Mono.just("test") + .delayElement(Duration.ofMillis(100)) + .repeat()))) + .bindNow(); + + Flux ws = client.websocket() + .uri("/") + .handle((in, out) -> in.aggregateFrames() + .receive() + .asString()); List expected = Flux.range(1, c) @@ -196,27 +169,23 @@ void unidirectional() { .verify(Duration.ofSeconds(5)); } - @Test - void webSocketRespondsToRequestsFromClients() { + void doWebSocketRespondsToRequestsFromClients(HttpServer server, HttpClient client) { AtomicInteger clientRes = new AtomicInteger(); AtomicInteger serverRes = new AtomicInteger(); disposableServer = - createServer() - .route(r -> r.get("/test/{param}", (req, res) -> { - log.debug(req.requestHeaders().get("test")); - return res.header("content-type", "text/plain") - .sendWebsocket((in, out) -> - out.sendString(in.receive() - .asString() - .publishOn(Schedulers.single()) - .doOnNext(s -> serverRes.incrementAndGet()) - .map(it -> it + ' ' + req.param("param") + '!') - .log("server-reply"))); - })) - .bindNow(Duration.ofSeconds(5)); - - HttpClient client = createClient(disposableServer.port()); + server.handle((req, res) -> { + log.debug(req.requestHeaders().get("test")); + return res.header("content-type", "text/plain") + .sendWebsocket((in, out) -> + out.sendString(in.receive() + .asString() + .publishOn(Schedulers.single()) + .doOnNext(s -> serverRes.incrementAndGet()) + .map(it -> it + '!') + .log("server-reply"))); + }) + .bindNow(Duration.ofSeconds(5)); Mono> response = client.headers(h -> h.add("Content-Type", "text/plain") @@ -244,30 +213,27 @@ void webSocketRespondsToRequestsFromClients() { log.debug("STARTING: server[" + serverRes.get() + "] / client[" + clientRes.get() + "]"); StepVerifier.create(response) - .expectNextMatches(list -> "1000 World!".equals(list.get(999))) + .expectNextMatches(list -> "1000!".equals(list.get(999))) .expectComplete() .verify(Duration.ofSeconds(5)); log.debug("FINISHED: server[" + serverRes.get() + "] / client[" + clientRes + "]"); } - @Test - void unidirectionalBinary() { + void doUnidirectionalBinary(HttpServer server, HttpClient client) { int c = 10; - disposableServer = createServer() - .handle((in, out) -> out.sendWebsocket( - (i, o) -> o.sendByteArray( - Mono.just("test".getBytes(Charset.defaultCharset())) - .delayElement(Duration.ofMillis(100)) - .repeat()))) - .bindNow(); - - Flux ws = createClient(disposableServer.port()) - .websocket() - .uri("/test") - .handle((i, o) -> i.aggregateFrames() - .receive() - .asString()); + disposableServer = server.handle((in, out) -> out.sendWebsocket( + (i, o) -> o.sendByteArray( + Mono.just("test".getBytes(Charset.defaultCharset())) + .delayElement(Duration.ofMillis(100)) + .repeat()))) + .bindNow(); + + Flux ws = client.websocket() + .uri("/test") + .handle((i, o) -> i.aggregateFrames() + .receive() + .asString()); List expected = Flux.range(1, c) @@ -283,196 +249,171 @@ void unidirectionalBinary() { .verify(Duration.ofSeconds(5)); } - @Test - void duplexEcho() throws Exception { - + void doDuplexEcho(HttpServer server, HttpClient client) throws Exception { int c = 10; CountDownLatch clientLatch = new CountDownLatch(c); CountDownLatch serverLatch = new CountDownLatch(c); - disposableServer = createServer() - .handle((in, out) -> out.sendWebsocket((i, o) -> o.sendString( - i.receive() - .asString() - .take(c) - .doOnNext(s -> serverLatch.countDown()) - .log("server")))) - .bindNow(); + disposableServer = server.handle((in, out) -> out.sendWebsocket((i, o) -> o.sendString( + i.receive() + .asString() + .take(c) + .doOnNext(s -> serverLatch.countDown()) + .log("server")))) + .bindNow(); Flux flux = Flux.interval(Duration.ofMillis(200)) .map(Object::toString); - createClient(disposableServer.port()) - .websocket() - .uri("/test") - .handle((i, o) -> o.sendString(Flux.merge(flux, i.receive() - .asString() - .doOnNext(s -> clientLatch.countDown()) - .log("client")))) - .log() - .subscribe(); + client.websocket() + .uri("/test") + .handle((i, o) -> o.sendString(Flux.merge(flux, i.receive() + .asString() + .doOnNext(s -> clientLatch.countDown()) + .log("client")))) + .log() + .subscribe(); assertThat(serverLatch.await(10, TimeUnit.SECONDS)).as("latch await").isTrue(); assertThat(clientLatch.await(10, TimeUnit.SECONDS)).as("latch await").isTrue(); } - @Test - void simpleSubprotocolServerNoSubprotocol() { - disposableServer = createServer() - .handle((in, out) -> out.sendWebsocket((i, o) -> o.sendString(Mono.just("test")))) - .bindNow(); + void doSimpleSubProtocolServerNoSubProtocol(HttpServer server, HttpClient client, String errorMessage) { + disposableServer = server.handle((in, out) -> out.sendWebsocket((i, o) -> o.sendString(Mono.just("test")))) + .bindNow(); StepVerifier.create( - createClient(disposableServer.port()) - .headers(h -> h.add("Authorization", auth)) - .websocket(WebsocketClientSpec.builder().protocols("SUBPROTOCOL,OTHER").build()) - .uri("/test") - .handle((i, o) -> i.receive().asString())) - .verifyErrorMessage("Invalid subprotocol. Actual: null. Expected one of: SUBPROTOCOL,OTHER"); + client.headers(h -> h.add("Authorization", auth)) + .websocket(WebsocketClientSpec.builder().protocols("SUBPROTOCOL,OTHER").build()) + .uri("/test") + .handle((i, o) -> i.receive().asString())) + .verifyErrorMessage(errorMessage); } - @Test - void simpleSubprotocolServerNotSupported() { - disposableServer = createServer() - .handle((in, out) -> out.sendWebsocket( - (i, o) -> o.sendString(Mono.just("test")), - WebsocketServerSpec.builder().protocols("protoA,protoB").build())) - .bindNow(); + void doSimpleSubProtocolServerNotSupported(HttpServer server, HttpClient client, String errorMessage) { + disposableServer = server.handle((in, out) -> out.sendWebsocket( + (i, o) -> o.sendString(Mono.just("test")), + WebsocketServerSpec.builder().protocols("protoA,protoB").build())) + .bindNow(); StepVerifier.create( - createClient(disposableServer.port()) - .headers(h -> h.add("Authorization", auth)) - .websocket(WebsocketClientSpec.builder().protocols("SUBPROTOCOL,OTHER").build()) - .uri("/test") - .handle((i, o) -> i.receive().asString())) + client.headers(h -> h.add("Authorization", auth)) + .websocket(WebsocketClientSpec.builder().protocols("SUBPROTOCOL,OTHER").build()) + .uri("/test") + .handle((i, o) -> i.receive().asString())) //the SERVER returned null which means that it couldn't select a protocol - .verifyErrorMessage("Invalid subprotocol. Actual: null. Expected one of: SUBPROTOCOL,OTHER"); + .verifyErrorMessage(errorMessage); } - @Test - void simpleSubprotocolServerSupported() { - disposableServer = createServer() - .handle((in, out) -> out.sendWebsocket( - (i, o) -> o.sendString(Mono.just("test")), - WebsocketServerSpec.builder().protocols("SUBPROTOCOL").build())) - .bindNow(); + void doSimpleSubProtocolServerSupported(HttpServer server, HttpClient client) { + disposableServer = server.handle((in, out) -> out.sendWebsocket( + (i, o) -> o.sendString(Mono.just("test")), + WebsocketServerSpec.builder().protocols("SUBPROTOCOL").build())) + .bindNow(); List res = - createClient(disposableServer.port()) - .headers(h -> h.add("Authorization", auth)) - .websocket(WebsocketClientSpec.builder().protocols("SUBPROTOCOL,OTHER").build()) - .uri("/test") - .handle((i, o) -> i.receive().asString()) - .log() - .collectList() - .block(Duration.ofSeconds(30)); + client.headers(h -> h.add("Authorization", auth)) + .websocket(WebsocketClientSpec.builder().protocols("SUBPROTOCOL,OTHER").build()) + .uri("/test") + .handle((i, o) -> i.receive().asString()) + .log() + .collectList() + .block(Duration.ofSeconds(30)); assertThat(res).isNotNull(); assertThat(res.get(0)).isEqualTo("test"); } - @Test - void simpleSubprotocolSelected() { - disposableServer = createServer() - .handle((in, out) -> out.sendWebsocket( - (i, o) -> o.sendString( - Mono.just("SERVER:" + o.selectedSubprotocol())), - WebsocketServerSpec.builder().protocols("NOT, Common").build())) - .bindNow(); + void doSimpleSubProtocolSelected(HttpServer server, HttpClient client) { + disposableServer = server.handle((in, out) -> out.sendWebsocket( + (i, o) -> o.sendString( + Mono.just("SERVER:" + o.selectedSubprotocol())), + WebsocketServerSpec.builder().protocols("NOT, Common").build())) + .bindNow(); List res = - createClient(disposableServer.port()) - .headers(h -> h.add("Authorization", auth)) - .websocket(WebsocketClientSpec.builder().protocols("Common,OTHER").build()) - .uri("/test") - .handle((in, out) -> in.receive() - .asString() - .map(srv -> "CLIENT:" + in.selectedSubprotocol() + "-" + srv)) - .log() - .collectList() - .block(Duration.ofSeconds(30)); + client.headers(h -> h.add("Authorization", auth)) + .websocket(WebsocketClientSpec.builder().protocols("Common,OTHER").build()) + .uri("/test") + .handle((in, out) -> in.receive() + .asString() + .map(srv -> "CLIENT:" + in.selectedSubprotocol() + "-" + srv)) + .log() + .collectList() + .block(Duration.ofSeconds(30)); assertThat(res).isNotNull(); assertThat(res.get(0)).isEqualTo("CLIENT:Common-SERVER:Common"); } - @Test - void noSubprotocolSelected() { - disposableServer = createServer() - .handle((in, out) -> out.sendWebsocket((i, o) -> o.sendString( - Mono.just("SERVER:" + o.selectedSubprotocol())))) - .bindNow(); + void doNoSubProtocolSelected(HttpServer server, HttpClient client) { + disposableServer = server.handle((in, out) -> out.sendWebsocket((i, o) -> o.sendString( + Mono.just("SERVER:" + o.selectedSubprotocol())))) + .bindNow(); List res = - createClient(disposableServer.port()) - .headers(h -> h.add("Authorization", auth)) - .websocket() - .uri("/test") - .handle((in, out) -> in.receive() - .asString() - .map(srv -> "CLIENT:" + in.selectedSubprotocol() + "-" + srv)) - .log() - .collectList() - .block(Duration.ofSeconds(30)); + client.headers(h -> h.add("Authorization", auth)) + .websocket() + .uri("/test") + .handle((in, out) -> in.receive() + .asString() + .map(srv -> "CLIENT:" + in.selectedSubprotocol() + "-" + srv)) + .log() + .collectList() + .block(Duration.ofSeconds(30)); assertThat(res).isNotNull(); assertThat(res.get(0)).isEqualTo("CLIENT:null-SERVER:null"); } - @Test - void anySubprotocolSelectsFirstClientProvided() { - disposableServer = createServer() - .handle((in, out) -> out.sendWebsocket((i, o) -> o.sendString( - Mono.just("SERVER:" + o.selectedSubprotocol())), - WebsocketServerSpec.builder().protocols("proto2,*").build())) - .bindNow(); + void doAnySubProtocolSelectsFirstClientProvided(HttpServer server, HttpClient client) { + disposableServer = server.handle((in, out) -> out.sendWebsocket((i, o) -> o.sendString( + Mono.just("SERVER:" + o.selectedSubprotocol())), + WebsocketServerSpec.builder().protocols("proto2,*").build())) + .bindNow(); List res = - createClient(disposableServer.port()) - .headers(h -> h.add("Authorization", auth)) - .websocket(WebsocketClientSpec.builder().protocols("proto1, proto2").build()) - .uri("/test") - .handle((in, out) -> in.receive() - .asString() - .map(srv -> "CLIENT:" + in.selectedSubprotocol() + "-" + srv)) - .log() - .collectList() - .block(Duration.ofSeconds(30)); + client.headers(h -> h.add("Authorization", auth)) + .websocket(WebsocketClientSpec.builder().protocols("proto1, proto2").build()) + .uri("/test") + .handle((in, out) -> in.receive() + .asString() + .map(srv -> "CLIENT:" + in.selectedSubprotocol() + "-" + srv)) + .log() + .collectList() + .block(Duration.ofSeconds(30)); assertThat(res).isNotNull(); assertThat(res.get(0)).isEqualTo("CLIENT:proto1-SERVER:proto1"); } - @Test - void sendToWebsocketSubprotocol() throws InterruptedException { + void doSendToWebsocketSubProtocol(HttpServer server, HttpClient client) throws InterruptedException { AtomicReference serverSelectedProtocol = new AtomicReference<>(); AtomicReference clientSelectedProtocol = new AtomicReference<>(); AtomicReference clientSelectedProtocolWhenSimplyUpgrading = new AtomicReference<>(); CountDownLatch latch = new CountDownLatch(1); - disposableServer = createServer() - .handle((in, out) -> out.sendWebsocket( - (i, o) -> { - serverSelectedProtocol.set(i.selectedSubprotocol()); - latch.countDown(); - return i.receive() - .asString() - .doOnNext(System.err::println) - .then(); - }, - WebsocketServerSpec.builder().protocols("not,proto1").build())) - .bindNow(); - - createClient(disposableServer.port()) - .headers(h -> h.add("Authorization", auth)) - .websocket(WebsocketClientSpec.builder().protocols("proto1,proto2").build()) - .uri("/test") - .handle((in, out) -> { - clientSelectedProtocolWhenSimplyUpgrading.set(in.selectedSubprotocol()); - clientSelectedProtocol.set(out.selectedSubprotocol()); - return out.sendString(Mono.just("HELLO" + out.selectedSubprotocol())); - }) - .blockLast(Duration.ofSeconds(30)); + disposableServer = server.handle((in, out) -> out.sendWebsocket( + (i, o) -> { + serverSelectedProtocol.set(i.selectedSubprotocol()); + latch.countDown(); + return i.receive() + .asString() + .doOnNext(System.err::println) + .then(); + }, + WebsocketServerSpec.builder().protocols("not,proto1").build())) + .bindNow(); + + client.headers(h -> h.add("Authorization", auth)) + .websocket(WebsocketClientSpec.builder().protocols("proto1,proto2").build()) + .uri("/test") + .handle((in, out) -> { + clientSelectedProtocolWhenSimplyUpgrading.set(in.selectedSubprotocol()); + clientSelectedProtocol.set(out.selectedSubprotocol()); + return out.sendString(Mono.just("HELLO" + out.selectedSubprotocol())); + }) + .blockLast(Duration.ofSeconds(30)); assertThat(latch.await(30, TimeUnit.SECONDS)).as("latch await").isTrue(); assertThat(serverSelectedProtocol.get()).isEqualTo("proto1"); @@ -480,105 +421,82 @@ void sendToWebsocketSubprotocol() throws InterruptedException { assertThat(clientSelectedProtocolWhenSimplyUpgrading.get()).isEqualTo("proto1"); } - @Test - void testMaxFramePayloadLengthFailed() { - disposableServer = createServer() - .handle((in, out) -> out.sendWebsocket((i, o) -> o.sendString(Mono.just("12345678901")))) - .bindNow(); + void doTestMaxFramePayloadLengthFailed(HttpServer server, HttpClient client) { + disposableServer = server.handle((in, out) -> out.sendWebsocket((i, o) -> o.sendString(Mono.just("12345678901")))) + .bindNow(); - Mono response = createClient(disposableServer.port()) - .websocket(WebsocketClientSpec.builder().maxFramePayloadLength(10).build()) - .handle((in, out) -> in.receive() - .asString() - .map(srv -> srv)) - .log() - .then(); + Mono response = client.websocket(WebsocketClientSpec.builder().maxFramePayloadLength(10).build()) + .handle((in, out) -> in.receive() + .asString() + .map(srv -> srv)) + .log() + .then(); StepVerifier.create(response) .expectError(CorruptedFrameException.class) .verify(Duration.ofSeconds(30)); } - @Test - void testMaxFramePayloadLengthSuccess() { - disposableServer = createServer() - .handle((in, out) -> out.sendWebsocket((i, o) -> o.sendString(Mono.just("12345678901")))) - .bindNow(); + void doTestMaxFramePayloadLengthSuccess(HttpServer server, HttpClient client) { + disposableServer = server.handle((in, out) -> out.sendWebsocket((i, o) -> o.sendString(Mono.just("12345678901")))) + .bindNow(); - Mono response = createClient(disposableServer.port()) - .websocket(WebsocketClientSpec.builder().maxFramePayloadLength(11).build()) - .handle((in, out) -> in.receive() - .asString() - .map(srv -> srv)) - .log() - .then(); + Mono response = client.websocket(WebsocketClientSpec.builder().maxFramePayloadLength(11).build()) + .handle((in, out) -> in.receive() + .asString() + .map(srv -> srv)) + .log() + .then(); StepVerifier.create(response) .expectComplete() .verify(Duration.ofSeconds(30)); } - @Test - void testServerMaxFramePayloadLengthFailed() { - doTestServerMaxFramePayloadLength(10, - Flux.just("1", "2", "12345678901", "3"), Flux.just("1", "2"), 2); - } - - @Test - void testServerMaxFramePayloadLengthSuccess() { - doTestServerMaxFramePayloadLength(11, - Flux.just("1", "2", "12345678901", "3"), Flux.just("1", "2", "12345678901", "3"), 4); - } - - private void doTestServerMaxFramePayloadLength(int maxFramePayloadLength, Flux input, Flux expectation, int count) { + void doTestServerMaxFramePayloadLength(HttpServer server, HttpClient client, + int maxFramePayloadLength, Flux input, Flux expectation, int count) { disposableServer = - createServer() - .handle((req, res) -> res.sendWebsocket((in, out) -> - out.sendObject(in.aggregateFrames() - .receiveFrames() - .map(WebSocketFrame::content) - .map(byteBuf -> - byteBuf.readCharSequence(byteBuf.readableBytes(), Charset.defaultCharset()).toString()) - .map(TextWebSocketFrame::new)), - WebsocketServerSpec.builder().maxFramePayloadLength(maxFramePayloadLength).build())) - .bindNow(); + server.handle((req, res) -> res.sendWebsocket((in, out) -> + out.sendObject(in.aggregateFrames() + .receiveFrames() + .map(WebSocketFrame::content) + .map(byteBuf -> + byteBuf.readCharSequence(byteBuf.readableBytes(), Charset.defaultCharset()).toString()) + .map(TextWebSocketFrame::new)), + WebsocketServerSpec.builder().maxFramePayloadLength(maxFramePayloadLength).build())) + .bindNow(); AtomicReference> output = new AtomicReference<>(new ArrayList<>()); - createClient(disposableServer.port()) - .websocket() - .uri("/") - .handle((in, out) -> out.sendString(input) - .then(in.aggregateFrames() - .receiveFrames() - .map(WebSocketFrame::content) - .map(byteBuf -> - byteBuf.readCharSequence(byteBuf.readableBytes(), Charset.defaultCharset()).toString()) - .take(count) - .doOnNext(s -> output.get().add(s)) - .then())) - .blockLast(Duration.ofSeconds(30)); + client.websocket() + .uri("/") + .handle((in, out) -> out.sendString(input) + .then(in.aggregateFrames() + .receiveFrames() + .map(WebSocketFrame::content) + .map(byteBuf -> + byteBuf.readCharSequence(byteBuf.readableBytes(), Charset.defaultCharset()).toString()) + .take(count) + .doOnNext(s -> output.get().add(s)) + .then())) + .blockLast(Duration.ofSeconds(30)); List test = expectation.collectList().block(Duration.ofSeconds(30)); assertThat(output.get()).isEqualTo(test); } - - @Test - void closePool() { + void doClosePool(HttpServer server, HttpClient client) { ConnectionProvider pr = ConnectionProvider.create("closePool", 1); - disposableServer = createServer() - .handle((in, out) -> out.sendWebsocket( - (i, o) -> o.sendString( - Mono.just("test") - .delayElement(Duration.ofMillis(100)) - .repeat()))) - .bindNow(); - - Flux ws = createClient(disposableServer.port()) - .websocket() - .uri("/") - .receive() - .asString(); + disposableServer = server.handle((in, out) -> out.sendWebsocket( + (i, o) -> o.sendString( + Mono.just("test") + .delayElement(Duration.ofMillis(100)) + .repeat()))) + .bindNow(); + + Flux ws = client.websocket() + .uri("/") + .receive() + .asString(); List expected = Flux.range(1, 20) @@ -598,23 +516,20 @@ void closePool() { pr.dispose(); } - @Test - void testCloseWebSocketFrameSentByServer() { + void doTestCloseWebSocketFrameSentByServer(HttpServer server, HttpClient client) { disposableServer = - createServer() - .handle((req, res) -> - res.sendWebsocket((in, out) -> out.sendObject(in.receiveFrames() - .doOnNext(WebSocketFrame::retain)))) - .bindNow(); + server.handle((req, res) -> + res.sendWebsocket((in, out) -> out.sendObject(in.receiveFrames() + .doOnNext(WebSocketFrame::retain)))) + .bindNow(); Flux response = - createClient(disposableServer.port()) - .websocket() - .uri("/") - .handle((in, out) -> out.sendString(Mono.just("echo")) - .sendObject(new CloseWebSocketFrame()) - .then() - .thenMany(in.receiveFrames())); + client.websocket() + .uri("/") + .handle((in, out) -> out.sendString(Mono.just("echo")) + .sendObject(new CloseWebSocketFrame()) + .then() + .thenMany(in.receiveFrames())); StepVerifier.create(response) .expectNextMatches(webSocketFrame -> @@ -624,225 +539,128 @@ void testCloseWebSocketFrameSentByServer() { .verify(Duration.ofSeconds(30)); } - @Test - void testCloseWebSocketFrameSentByClient() { + void doTestCloseWebSocketFrameSentByClient(HttpServer server, HttpClient client) { disposableServer = - createServer() - .handle((req, res) -> - res.sendWebsocket((in, out) -> out.sendString(Mono.just("echo")) - .sendObject(new CloseWebSocketFrame()))) - .bindNow(); + server.handle((req, res) -> + res.sendWebsocket((in, out) -> out.sendString(Mono.just("echo")) + .sendObject(new CloseWebSocketFrame()))) + .bindNow(); Mono response = - createClient(disposableServer.port()) - .websocket() - .uri("/") - .handle((in, out) -> out.sendObject(in.receiveFrames() - .doOnNext(WebSocketFrame::retain) - .then())) - .next(); + client.websocket() + .uri("/") + .handle((in, out) -> out.sendObject(in.receiveFrames() + .doOnNext(WebSocketFrame::retain) + .then())) + .next(); StepVerifier.create(response) .expectComplete() .verify(Duration.ofSeconds(30)); } - @Test - void testConnectionAliveWhenTransformationErrors_1() { - doTestConnectionAliveWhenTransformationErrors((in, out) -> - out.sendObject(in.aggregateFrames() - .receiveFrames() - .map(WebSocketFrame::content) - //.share() - .publish() - .autoConnect() - .map(byteBuf -> - byteBuf.readCharSequence(byteBuf.readableBytes(), Charset.defaultCharset()).toString()) - .map(Integer::parseInt) - .map(i -> new TextWebSocketFrame(i + "")) - .retry()), - Flux.just("1", "2"), 2); - } - - @Test - void testConnectionAliveWhenTransformationErrors_2() { - doTestConnectionAliveWhenTransformationErrors((in, out) -> - out.sendObject(in.aggregateFrames() - .receiveFrames() - .map(WebSocketFrame::content) - .concatMap(content -> - Mono.just(content) - .map(byteBuf -> - byteBuf.readCharSequence(byteBuf.readableBytes(), Charset.defaultCharset()).toString()) - .map(Integer::parseInt) - .map(i -> new TextWebSocketFrame(i + "")) - .onErrorResume(t -> Mono.just(new TextWebSocketFrame("error"))))), - Flux.just("1", "error", "2"), 3); - } - - private void doTestConnectionAliveWhenTransformationErrors(BiFunction> handler, + void doTestConnectionAliveWhenTransformationErrors(HttpServer server, HttpClient client, + BiFunction> handler, Flux expectation, int count) { disposableServer = - createServer() - .handle((req, res) -> res.sendWebsocket(handler)) - .bindNow(); + server.handle((req, res) -> res.sendWebsocket(handler)) + .bindNow(); AtomicReference> output = new AtomicReference<>(new ArrayList<>()); - createClient(disposableServer.port()) - .websocket() - .uri("/") - .handle((in, out) -> out.sendString(Flux.just("1", "text", "2")) - .then(in.aggregateFrames() - .receiveFrames() - .map(WebSocketFrame::content) - .map(byteBuf -> - byteBuf.readCharSequence(byteBuf.readableBytes(), Charset.defaultCharset()).toString()) - .take(count) - .doOnNext(s -> output.get().add(s)) - .then())) - .blockLast(Duration.ofSeconds(30)); + client.websocket() + .uri("/") + .handle((in, out) -> out.sendString(Flux.just("1", "text", "2")) + .then(in.aggregateFrames() + .receiveFrames() + .map(WebSocketFrame::content) + .map(byteBuf -> + byteBuf.readCharSequence(byteBuf.readableBytes(), Charset.defaultCharset()).toString()) + .take(count) + .doOnNext(s -> output.get().add(s)) + .then())) + .blockLast(Duration.ofSeconds(30)); List test = expectation.collectList().block(Duration.ofSeconds(30)); assertThat(output.get()).isEqualTo(test); - - } - - @Test - void testClientOnCloseIsInvokedClientSendClose() throws Exception { - disposableServer = - createServer() - .handle((req, res) -> - res.sendWebsocket((in, out) -> - out.sendString(Flux.interval(Duration.ofSeconds(1)) - .map(l -> l + "")))) - .bindNow(); - - CountDownLatch latch = new CountDownLatch(3); - AtomicBoolean error = new AtomicBoolean(); - createClient(disposableServer.port()) - .websocket() - .uri("/test") - .handle((in, out) -> { - Mono.delay(Duration.ofSeconds(3)) - .delayUntil(i -> out.sendClose()) - .subscribe(c -> { - log.debug("context.dispose()"); - latch.countDown(); - }); - in.withConnection(conn -> - conn.onDispose() - .subscribe( - c -> { // no-op - }, - t -> { - t.printStackTrace(); - error.set(true); - }, - () -> { - log.debug("context.onClose() completed"); - latch.countDown(); - })); - Mono.delay(Duration.ofSeconds(3)) - .repeat(() -> { - AtomicBoolean disposed = new AtomicBoolean(false); - in.withConnection(conn -> { - disposed.set(conn.isDisposed()); - log.debug("context.isDisposed() " + conn.isDisposed()); - }); - if (disposed.get()) { - latch.countDown(); - return false; - } - return true; - }) - .subscribe(); - return Mono.delay(Duration.ofSeconds(7)) - .then(); - }) - .blockLast(Duration.ofSeconds(30)); - - assertThat(latch.await(30, TimeUnit.SECONDS)).as("latch await").isTrue(); - - assertThat(error.get()).isFalse(); } - @Test - void testClientOnCloseIsInvokedClientDisposed() throws Exception { + void doTestClientOnCloseIsInvokedClientSendClose(HttpServer server, HttpClient client) throws Exception { disposableServer = - createServer() - .handle((req, res) -> - res.sendWebsocket((in, out) -> - out.sendString(Flux.interval(Duration.ofSeconds(1)) - .map(l -> l + "")))) - .bindNow(); + server.handle((req, res) -> + res.sendWebsocket((in, out) -> + out.sendString(Flux.interval(Duration.ofSeconds(1)) + .map(l -> l + "")))) + .bindNow(); CountDownLatch latch = new CountDownLatch(3); AtomicBoolean error = new AtomicBoolean(); - createClient(disposableServer.port()) - .websocket() - .uri("/test") - .handle((in, out) -> { - in.withConnection(conn -> { - Mono.delay(Duration.ofSeconds(3)) - .subscribe(c -> { - log.debug("context.dispose()"); - conn.dispose(); - latch.countDown(); - }); - conn.onDispose() - .subscribe( - c -> { // no-op - }, - t -> { - t.printStackTrace(); - error.set(true); - }, - () -> { - log.debug("context.onClose() completed"); - latch.countDown(); - }); + client.websocket() + .uri("/test") + .handle((in, out) -> { + Mono.delay(Duration.ofSeconds(3)) + .delayUntil(i -> out.sendClose()) + .subscribe(c -> { + log.debug("context.dispose()"); + latch.countDown(); }); - Mono.delay(Duration.ofSeconds(3)) - .repeat(() -> { - AtomicBoolean disposed = new AtomicBoolean(false); - in.withConnection(conn -> { - disposed.set(conn.isDisposed()); - log.debug("context.isDisposed() " + conn.isDisposed()); - }); - if (disposed.get()) { + in.withConnection(conn -> + conn.onDispose() + .subscribe( + c -> { // no-op + }, + t -> { + t.printStackTrace(); + error.set(true); + }, + () -> { + log.debug("context.onClose() completed"); latch.countDown(); - return false; - } - return true; - }) - .subscribe(); - return Mono.delay(Duration.ofSeconds(7)) - .then(); - }) - .blockLast(Duration.ofSeconds(30)); + })); + Mono.delay(Duration.ofSeconds(3)) + .repeat(() -> { + AtomicBoolean disposed = new AtomicBoolean(false); + in.withConnection(conn -> { + disposed.set(conn.isDisposed()); + log.debug("context.isDisposed() " + conn.isDisposed()); + }); + if (disposed.get()) { + latch.countDown(); + return false; + } + return true; + }) + .subscribe(); + return Mono.delay(Duration.ofSeconds(7)) + .then(); + }) + .blockLast(Duration.ofSeconds(30)); assertThat(latch.await(30, TimeUnit.SECONDS)).as("latch await").isTrue(); assertThat(error.get()).isFalse(); } - @Test - void testClientOnCloseIsInvokedServerInitiatedClose() throws Exception { + void doTestClientOnCloseIsInvokedClientDisposed(HttpServer server, HttpClient client) throws Exception { disposableServer = - createServer() - .handle((req, res) -> - res.sendWebsocket((in, out) -> - out.sendString(Mono.just("test")))) - .bindNow(); + server.handle((req, res) -> + res.sendWebsocket((in, out) -> + out.sendString(Flux.interval(Duration.ofSeconds(1)) + .map(l -> l + "")))) + .bindNow(); - CountDownLatch latch = new CountDownLatch(2); + CountDownLatch latch = new CountDownLatch(3); AtomicBoolean error = new AtomicBoolean(); - createClient(disposableServer.port()) - .websocket() - .uri("/test") - .handle((in, out) -> { - in.withConnection(conn -> - conn.onDispose() + client.websocket() + .uri("/test") + .handle((in, out) -> { + in.withConnection(conn -> { + Mono.delay(Duration.ofSeconds(3)) + .subscribe(c -> { + log.debug("context.dispose()"); + conn.dispose(); + latch.countDown(); + }); + conn.onDispose() .subscribe( c -> { // no-op }, @@ -853,7 +671,8 @@ void testClientOnCloseIsInvokedServerInitiatedClose() throws Exception { () -> { log.debug("context.onClose() completed"); latch.countDown(); - })); + }); + }); Mono.delay(Duration.ofSeconds(3)) .repeat(() -> { AtomicBoolean disposed = new AtomicBoolean(false); @@ -868,26 +687,69 @@ void testClientOnCloseIsInvokedServerInitiatedClose() throws Exception { return true; }) .subscribe(); - return in.receive(); - }) - .blockLast(Duration.ofSeconds(30)); + return Mono.delay(Duration.ofSeconds(7)) + .then(); + }) + .blockLast(Duration.ofSeconds(30)); + + assertThat(latch.await(30, TimeUnit.SECONDS)).as("latch await").isTrue(); + + assertThat(error.get()).isFalse(); + } + + void doTestClientOnCloseIsInvokedServerInitiatedClose(HttpServer server, HttpClient client) throws Exception { + disposableServer = + server.handle((req, res) -> res.sendWebsocket((in, out) -> out.sendString(Mono.just("test")))) + .bindNow(); + + CountDownLatch latch = new CountDownLatch(2); + AtomicBoolean error = new AtomicBoolean(); + client.websocket() + .uri("/test") + .handle((in, out) -> { + in.withConnection(conn -> + conn.onDispose() + .subscribe( + c -> { // no-op + }, + t -> { + t.printStackTrace(); + error.set(true); + }, + () -> { + log.debug("context.onClose() completed"); + latch.countDown(); + })); + Mono.delay(Duration.ofSeconds(3)) + .repeat(() -> { + AtomicBoolean disposed = new AtomicBoolean(false); + in.withConnection(conn -> { + disposed.set(conn.isDisposed()); + log.debug("context.isDisposed() " + conn.isDisposed()); + }); + if (disposed.get()) { + latch.countDown(); + return false; + } + return true; + }) + .subscribe(); + return in.receive(); + }) + .blockLast(Duration.ofSeconds(30)); assertThat(latch.await(30, TimeUnit.SECONDS)).isTrue(); assertThat(error.get()).isFalse(); } - @Test - void testIssue460() { + void doTestIssue460(HttpServer server, HttpClient client) { disposableServer = - createServer() - .host("::1") - .handle((req, res) -> res.sendWebsocket((in, out) -> Mono.never())) - .bindNow(); + server.host("::1") + .handle((req, res) -> res.sendWebsocket((in, out) -> Mono.never())) + .bindNow(); - HttpClient httpClient = - createClient(disposableServer::address) - .headers(h -> h.add(HttpHeaderNames.HOST, "[::1")); + HttpClient httpClient = client.headers(h -> h.add(HttpHeaderNames.HOST, "[::1")); StepVerifier.create(httpClient.websocket() .connect()) @@ -895,104 +757,47 @@ void testIssue460() { .verify(Duration.ofSeconds(30)); } - @Test - void testIssue444_1() { - doTestIssue444((in, out) -> - out.sendObject(Flux.error(new Throwable()) - .onErrorResume(ex -> out.sendClose(1001, "Going Away")) - .cast(WebSocketFrame.class))); - } - - @Test - void testIssue444_2() { - doTestIssue444((in, out) -> - out.send(Flux.range(0, 10) - .map(i -> { - if (i == 5) { - out.sendClose(1001, "Going Away").subscribe(); - } - return Unpooled.copiedBuffer((i + "").getBytes(Charset.defaultCharset())); - }))); - } - - @Test - void testIssue444_3() { - doTestIssue444((in, out) -> - out.sendObject(Flux.error(new Throwable()) - .onErrorResume(ex -> Flux.empty()) - .cast(WebSocketFrame.class)) - .then(Mono.defer(() -> out.sendObject( - new CloseWebSocketFrame(1001, "Going Away")).then()))); - } - - private void doTestIssue444(BiFunction> fn) { + void doTestIssue444(HttpServer server, HttpClient client, BiFunction> fn) { disposableServer = - createServer() - .host("localhost") - .handle((req, res) -> res.sendWebsocket(fn)) - .bindNow(); + server.host("localhost") + .handle((req, res) -> res.sendWebsocket(fn)) + .bindNow(); StepVerifier.create( - createClient(disposableServer::address) - .websocket() - .uri("/") - .handle((i, o) -> i.receiveFrames() - .then())) + client.websocket() + .uri("/") + .handle((i, o) -> i.receiveFrames() + .then())) .expectComplete() .verify(Duration.ofSeconds(30)); } - // https://bugzilla.mozilla.org/show_bug.cgi?id=691300 - @Test - void firefoxConnectionTest() { - disposableServer = createServer() - .route(r -> r.ws("/ws", (in, out) -> out.sendString(Mono.just("test")))) - .bindNow(); - - HttpClientResponse res = - createClient(disposableServer.port()) - .headers(h -> { - h.add(HttpHeaderNames.CONNECTION, "keep-alive, Upgrade"); - h.add(HttpHeaderNames.UPGRADE, "websocket"); - h.add(HttpHeaderNames.ORIGIN, "http://localhost"); - }) - .get() - .uri("/ws") - .response() - .block(Duration.ofSeconds(5)); - assertThat(res).isNotNull(); - assertThat(res.status()).isEqualTo(HttpResponseStatus.SWITCHING_PROTOCOLS); - } - - @Test - void testIssue821() throws Exception { + void doTestIssue821(HttpServer server, HttpClient client) throws Exception { Scheduler scheduler = Schedulers.newSingle("ws"); CountDownLatch latch = new CountDownLatch(1); AtomicReference error = new AtomicReference<>(); - disposableServer = createServer() - .route(r -> r.ws("/ws", (in, out) -> { - scheduler.schedule(() -> - out.sendString(Mono.just("scheduled")) - .then() - .subscribe( - null, - t -> { - error.set(t); - latch.countDown(); - }, - null), - 500, TimeUnit.MILLISECONDS); - return out.sendString(Mono.just("test")); - })) - .bindNow(); + disposableServer = server.route(r -> r.ws("/ws", (in, out) -> { + scheduler.schedule(() -> + out.sendString(Mono.just("scheduled")) + .then() + .subscribe( + null, + t -> { + error.set(t); + latch.countDown(); + }, + null), + 500, TimeUnit.MILLISECONDS); + return out.sendString(Mono.just("test")); + })) + .bindNow(); String res = - createClient(disposableServer.port()) - .websocket() - .uri("/ws") - .receive() - .asString() - .blockLast(Duration.ofSeconds(5)); + client.websocket() + .uri("/ws") + .receive() + .asString() + .blockLast(Duration.ofSeconds(5)); assertThat(res).isNotNull() .isEqualTo("test"); @@ -1005,35 +810,32 @@ void testIssue821() throws Exception { scheduler.dispose(); } - @Test - void testIssue900_1() throws Exception { + void doTestIssue900_1(HttpServer server, HttpClient client) throws Exception { AtomicReference statusClient = new AtomicReference<>(); disposableServer = - createServer() - .handle((req, res) -> - res.sendWebsocket((in, out) -> out.sendObject(in.receiveFrames() - .doOnNext(WebSocketFrame::retain)))) - .bindNow(); + server.handle((req, res) -> + res.sendWebsocket((in, out) -> out.sendObject(in.receiveFrames() + .doOnNext(WebSocketFrame::retain)))) + .bindNow(); CountDownLatch latch = new CountDownLatch(1); Flux response = - createClient(disposableServer.port()) - .websocket() - .uri("/") - .handle((in, out) -> { - in.receiveCloseStatus() - .doOnNext(o -> { - statusClient.set(o); - latch.countDown(); - }) - .subscribe(); - - return out.sendObject(Flux.just(new TextWebSocketFrame("echo"), - new CloseWebSocketFrame(1008, "something"))) - .then() - .thenMany(in.receiveFrames()); - }); + client.websocket() + .uri("/") + .handle((in, out) -> { + in.receiveCloseStatus() + .doOnNext(o -> { + statusClient.set(o); + latch.countDown(); + }) + .subscribe(); + + return out.sendObject(Flux.just(new TextWebSocketFrame("echo"), + new CloseWebSocketFrame(1008, "something"))) + .then() + .thenMany(in.receiveFrames()); + }); StepVerifier.create(response) .expectNextMatches(webSocketFrame -> @@ -1047,43 +849,39 @@ void testIssue900_1() throws Exception { .isEqualTo(new WebSocketCloseStatus(1008, "something")); } - @Test - void testIssue900_2() throws Exception { + void doTestIssue900_2(HttpServer server, HttpClient client) throws Exception { AtomicReference statusServer = new AtomicReference<>(); AtomicReference incomingData = new AtomicReference<>(); CountDownLatch latch = new CountDownLatch(1); disposableServer = - createServer() - .handle((req, res) -> - res.sendWebsocket((in, out) -> { - in.receiveCloseStatus() - .doOnNext(o -> { - statusServer.set(o); - latch.countDown(); - }) - .subscribe(); - - return out.sendObject(Flux.just(new TextWebSocketFrame("echo"), - new CloseWebSocketFrame(1008, "something")) - .delayElements(Duration.ofMillis(100))) - .then(in.receiveFrames() - .doOnNext(o -> { - if (o instanceof TextWebSocketFrame) { - incomingData.set(((TextWebSocketFrame) o).text()); - } - }) - .then()); - }) - ) - .bindNow(); - - createClient(disposableServer.port()) - .websocket() - .uri("/") - .handle((in, out) -> out.sendObject(in.receiveFrames() - .doOnNext(WebSocketFrame::retain))) - .subscribe(); + server.handle((req, res) -> + res.sendWebsocket((in, out) -> { + in.receiveCloseStatus() + .doOnNext(o -> { + statusServer.set(o); + latch.countDown(); + }) + .subscribe(); + + return out.sendObject(Flux.just(new TextWebSocketFrame("echo"), + new CloseWebSocketFrame(1008, "something")) + .delayElements(Duration.ofMillis(100))) + .then(in.receiveFrames() + .doOnNext(o -> { + if (o instanceof TextWebSocketFrame) { + incomingData.set(((TextWebSocketFrame) o).text()); + } + }) + .then()); + })) + .bindNow(); + + client.websocket() + .uri("/") + .handle((in, out) -> out.sendObject(in.receiveFrames() + .doOnNext(WebSocketFrame::retain))) + .subscribe(); assertThat(latch.await(30, TimeUnit.SECONDS)).isTrue(); assertThat(incomingData.get()).isNotNull() @@ -1092,197 +890,164 @@ void testIssue900_2() throws Exception { .isEqualTo(new WebSocketCloseStatus(1008, "something")); } - @Test - void testIssue663_1() throws Exception { + void doTestIssue663_1(HttpServer server, HttpClient client) throws Exception { AtomicBoolean incomingData = new AtomicBoolean(); CountDownLatch latch = new CountDownLatch(1); disposableServer = - createServer() - .handle((req, resp) -> - resp.sendWebsocket((i, o) -> - o.sendObject(Flux.just(new PingWebSocketFrame(), new CloseWebSocketFrame()) - .delayElements(Duration.ofMillis(100))) - .then(i.receiveFrames() - .doOnNext(f -> { - if (f instanceof PongWebSocketFrame) { - incomingData.set(true); - } - }) - .doOnComplete(latch::countDown) - .then()))) - .bindNow(); - - createClient(disposableServer.port()) - .websocket() - .uri("/") - .handle((in, out) -> in.receiveFrames()) - .subscribe(); + server.handle((req, resp) -> + resp.sendWebsocket((i, o) -> + o.sendObject(Flux.just(new PingWebSocketFrame(), new CloseWebSocketFrame()) + .delayElements(Duration.ofMillis(100))) + .then(i.receiveFrames() + .doOnNext(f -> { + if (f instanceof PongWebSocketFrame) { + incomingData.set(true); + } + }) + .doOnComplete(latch::countDown) + .then()))) + .bindNow(); + + client.websocket() + .uri("/") + .handle((in, out) -> in.receiveFrames()) + .subscribe(); assertThat(latch.await(30, TimeUnit.SECONDS)).isTrue(); assertThat(incomingData.get()).isTrue(); } - @Test - void testIssue663_2() throws Exception { + void doTestIssue663_2(HttpServer server, HttpClient client) throws Exception { AtomicBoolean incomingData = new AtomicBoolean(); CountDownLatch latch = new CountDownLatch(1); disposableServer = - createServer() - .handle((req, resp) -> - resp.sendWebsocket((i, o) -> - o.sendObject(Flux.just(new PingWebSocketFrame(), new CloseWebSocketFrame()) - .delayElements(Duration.ofMillis(100))) - .then(i.receiveFrames() - .doOnNext(f -> incomingData.set(true)) - .doOnComplete(latch::countDown) - .then()))) - .bindNow(); - - createClient(disposableServer.port()) - .websocket(WebsocketClientSpec.builder().handlePing(true).build()) - .uri("/") - .handle((in, out) -> in.receiveFrames()) - .subscribe(); + server.handle((req, resp) -> + resp.sendWebsocket((i, o) -> + o.sendObject(Flux.just(new PingWebSocketFrame(), new CloseWebSocketFrame()) + .delayElements(Duration.ofMillis(100))) + .then(i.receiveFrames() + .doOnNext(f -> incomingData.set(true)) + .doOnComplete(latch::countDown) + .then()))) + .bindNow(); + + client.websocket(WebsocketClientSpec.builder().handlePing(true).build()) + .uri("/") + .handle((in, out) -> in.receiveFrames()) + .subscribe(); assertThat(latch.await(30, TimeUnit.SECONDS)).isTrue(); assertThat(incomingData.get()).isFalse(); } - @Test - void testIssue663_3() throws Exception { + void doTestIssue663_3(HttpServer server, HttpClient client) throws Exception { AtomicBoolean incomingData = new AtomicBoolean(); CountDownLatch latch = new CountDownLatch(1); disposableServer = - createServer() - .handle((req, resp) -> resp.sendWebsocket((i, o) -> i.receiveFrames().then())) - .bindNow(); - - createClient(disposableServer.port()) - .websocket() - .uri("/") - .handle((in, out) -> - out.sendObject(Flux.just(new PingWebSocketFrame(), new CloseWebSocketFrame()) - .delayElements(Duration.ofMillis(100))) - .then(in.receiveFrames() - .doOnNext(f -> { - if (f instanceof PongWebSocketFrame) { - incomingData.set(true); - } - }) - .doOnComplete(latch::countDown) - .then())) - .subscribe(); + server.handle((req, resp) -> resp.sendWebsocket((i, o) -> i.receiveFrames().then())) + .bindNow(); + + client.websocket() + .uri("/") + .handle((in, out) -> + out.sendObject(Flux.just(new PingWebSocketFrame(), new CloseWebSocketFrame()) + .delayElements(Duration.ofMillis(100))) + .then(in.receiveFrames() + .doOnNext(f -> { + if (f instanceof PongWebSocketFrame) { + incomingData.set(true); + } + }) + .doOnComplete(latch::countDown) + .then())) + .subscribe(); assertThat(latch.await(30, TimeUnit.SECONDS)).isTrue(); assertThat(incomingData.get()).isTrue(); } - @Test - void testIssue663_4() throws Exception { + void doTestIssue663_4(HttpServer server, HttpClient client) throws Exception { AtomicBoolean incomingData = new AtomicBoolean(); CountDownLatch latch = new CountDownLatch(1); disposableServer = - createServer() - .handle((req, resp) -> resp.sendWebsocket((i, o) -> i.receiveFrames().then(), - WebsocketServerSpec.builder().handlePing(true).build())) - .bindNow(); - - createClient(disposableServer.port()) - .websocket() - .uri("/") - .handle((in, out) -> - out.sendObject(Flux.just(new PingWebSocketFrame(), new CloseWebSocketFrame()) - .delayElements(Duration.ofMillis(100))) - .then(in.receiveFrames() - .doOnNext(f -> incomingData.set(true)) - .doOnComplete(latch::countDown) - .then())) - .subscribe(); + server.handle((req, resp) -> resp.sendWebsocket((i, o) -> i.receiveFrames().then(), + WebsocketServerSpec.builder().handlePing(true).build())) + .bindNow(); + + client.websocket() + .uri("/") + .handle((in, out) -> + out.sendObject(Flux.just(new PingWebSocketFrame(), new CloseWebSocketFrame()) + .delayElements(Duration.ofMillis(100))) + .then(in.receiveFrames() + .doOnNext(f -> incomingData.set(true)) + .doOnComplete(latch::countDown) + .then())) + .subscribe(); assertThat(latch.await(30, TimeUnit.SECONDS)).isTrue(); assertThat(incomingData.get()).isFalse(); } - - @Test - void testIssue967() throws Exception { + void doTestIssue967(HttpServer server, HttpClient client) throws Exception { Flux somePublisher = Flux.range(1, 10) .map(i -> Integer.toString(i)) .delayElements(Duration.ofMillis(50)); disposableServer = - createServer() - .handle((req, res) -> - res.sendWebsocket((in, out) -> - Mono.when(out.sendString(somePublisher), - in.receiveFrames() - .cast(TextWebSocketFrame.class) - .map(TextWebSocketFrame::text) - .publish() // We want the connection alive even after takeUntil - .autoConnect() // which will trigger cancel - .takeUntil(msg -> msg.equals("5")) - .then()))) - .bindNow(); + server.handle((req, res) -> + res.sendWebsocket((in, out) -> + Mono.when(out.sendString(somePublisher), + in.receiveFrames() + .cast(TextWebSocketFrame.class) + .map(TextWebSocketFrame::text) + .publish() // We want the connection alive even after takeUntil + .autoConnect() // which will trigger cancel + .takeUntil(msg -> msg.equals("5")) + .then()))) + .bindNow(); Flux toSend = Flux.range(1, 10) .map(i -> Integer.toString(i)); AtomicInteger count = new AtomicInteger(); CountDownLatch latch = new CountDownLatch(1); - createClient(disposableServer.port()) - .websocket() - .uri("/") - .handle((in, out) -> - Mono.when(out.sendString(toSend), - in.receiveFrames() - .cast(TextWebSocketFrame.class) - .map(TextWebSocketFrame::text) - .doOnNext(s -> count.getAndIncrement()) - .doOnComplete(latch::countDown) - .then())) - .subscribe(); + client.websocket() + .uri("/") + .handle((in, out) -> + Mono.when(out.sendString(toSend), + in.receiveFrames() + .cast(TextWebSocketFrame.class) + .map(TextWebSocketFrame::text) + .doOnNext(s -> count.getAndIncrement()) + .doOnComplete(latch::countDown) + .then())) + .subscribe(); assertThat(latch.await(30, TimeUnit.SECONDS)).isTrue(); assertThat(count.get()).isEqualTo(10); } - @Test - void testIssue970_WithCompress() { - doTestWebsocketCompression(true); - } - - @Test - void testIssue970_NoCompress() { - doTestWebsocketCompression(false); - } - - @Test - void testIssue2973() { - doTestWebsocketCompression(true, true); - } - - private void doTestWebsocketCompression(boolean compress) { - doTestWebsocketCompression(compress, false); + void doTestWebsocketCompression(HttpServer server, HttpClient client, boolean compress) { + doTestWebsocketCompression(server, client, compress, false); } - private void doTestWebsocketCompression(boolean compress, boolean clientServerNoContextTakeover) { + void doTestWebsocketCompression(HttpServer server, HttpClient client, boolean compress, boolean clientServerNoContextTakeover) { WebsocketServerSpec.Builder serverBuilder = WebsocketServerSpec.builder().compress(compress); WebsocketServerSpec websocketServerSpec = clientServerNoContextTakeover ? serverBuilder.compressionAllowServerNoContext(true).compressionPreferredClientNoContext(true).build() : serverBuilder.build(); disposableServer = - createServer() - .handle((req, res) -> - res.sendWebsocket((in, out) -> out.sendString(Mono.just("test")), websocketServerSpec)) - .bindNow(); + server.handle((req, res) -> + res.sendWebsocket((in, out) -> out.sendString(Mono.just("test")), websocketServerSpec)) + .bindNow(); AtomicBoolean clientHandler = new AtomicBoolean(); - HttpClient client = createClient(disposableServer::address); String perMessageDeflateEncoder = "io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateEncoder"; BiFunction>> receiver = @@ -1329,60 +1094,37 @@ private void doTestWebsocketCompression(boolean compress, boolean clientServerNo assertThat(clientHandler.get()).isEqualTo(compress); } - @Test - void websocketOperationsBadValues() throws Exception { - EmbeddedChannel channel = new EmbeddedChannel(); - HttpClientOperations parent = new HttpClientOperations(Connection.from(channel), - ConnectionObserver.emptyListener(), ClientCookieEncoder.STRICT, ClientCookieDecoder.STRICT, - ReactorNettyHttpMessageLogFactory.INSTANCE); - WebsocketClientOperations ops = new WebsocketClientOperations(new URI(""), - WebsocketClientSpec.builder().build(), parent); - - assertThatExceptionOfType(IllegalArgumentException.class) - .isThrownBy(() -> ops.aggregateFrames(-1)) - .withMessageEndingWith("-1 (expected: >= 0)"); - - assertThatExceptionOfType(NullPointerException.class) - .isThrownBy(() -> ops.send(null)); - - assertThatExceptionOfType(NullPointerException.class) - .isThrownBy(() -> ops.sendString(null, Charset.defaultCharset())); - } - - @Test - void testIssue1485_CloseFrameSentByClient() throws Exception { + void doTestIssue1485_CloseFrameSentByClient(HttpServer server, HttpClient client) throws Exception { AtomicReference statusServer = new AtomicReference<>(); AtomicReference statusClient = new AtomicReference<>(); CountDownLatch latch = new CountDownLatch(2); disposableServer = - createServer() - .handle((req, res) -> - res.sendWebsocket((in, out) -> { - in.receiveCloseStatus() - .doOnNext(status -> { - statusServer.set(status); - latch.countDown(); - }) - .subscribe(); - return in.receive().then(); - })) - .bindNow(); - - createClient(disposableServer.port()) - .websocket() - .uri("/") - .handle((in, out) -> { - in.receiveCloseStatus() - .doOnNext(status -> { - statusClient.set(status); - latch.countDown(); - }) - .subscribe(); - return out.sendObject(new CloseWebSocketFrame()) - .then(in.receive().then()); - }) - .blockLast(Duration.ofSeconds(5)); + server.handle((req, res) -> + res.sendWebsocket((in, out) -> { + in.receiveCloseStatus() + .doOnNext(status -> { + statusServer.set(status); + latch.countDown(); + }) + .subscribe(); + return in.receive().then(); + })) + .bindNow(); + + client.websocket() + .uri("/") + .handle((in, out) -> { + in.receiveCloseStatus() + .doOnNext(status -> { + statusClient.set(status); + latch.countDown(); + }) + .subscribe(); + return out.sendObject(new CloseWebSocketFrame()) + .then(in.receive().then()); + }) + .blockLast(Duration.ofSeconds(5)); assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); @@ -1395,40 +1137,37 @@ void testIssue1485_CloseFrameSentByClient() throws Exception { .isEqualTo(WebSocketCloseStatus.EMPTY); } - @Test - void testIssue1485_CloseFrameSentByServer() throws Exception { + void doTestIssue1485_CloseFrameSentByServer(HttpServer server, HttpClient client) throws Exception { AtomicReference statusServer = new AtomicReference<>(); AtomicReference statusClient = new AtomicReference<>(); CountDownLatch latch = new CountDownLatch(2); disposableServer = - createServer() - .handle((req, res) -> - res.sendWebsocket((in, out) -> { - in.receiveCloseStatus() - .doOnNext(status -> { - statusServer.set(status); - latch.countDown(); - }) - .subscribe(); - return out.sendObject(new CloseWebSocketFrame()) - .then(in.receive().then()); - })) - .bindNow(); - - createClient(disposableServer.port()) - .websocket() - .uri("/") - .handle((in, out) -> { - in.receiveCloseStatus() - .doOnNext(status -> { - statusClient.set(status); - latch.countDown(); - }) - .subscribe(); - return in.receive(); - }) - .blockLast(Duration.ofSeconds(5)); + server.handle((req, res) -> + res.sendWebsocket((in, out) -> { + in.receiveCloseStatus() + .doOnNext(status -> { + statusServer.set(status); + latch.countDown(); + }) + .subscribe(); + return out.sendObject(new CloseWebSocketFrame()) + .then(in.receive().then()); + })) + .bindNow(); + + client.websocket() + .uri("/") + .handle((in, out) -> { + in.receiveCloseStatus() + .doOnNext(status -> { + statusClient.set(status); + latch.countDown(); + }) + .subscribe(); + return in.receive(); + }) + .blockLast(Duration.ofSeconds(5)); assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); @@ -1441,128 +1180,62 @@ void testIssue1485_CloseFrameSentByServer() throws Exception { .isEqualTo(WebSocketCloseStatus.EMPTY); } - @Test - void testConnectionClosedWhenFailedUpgrade_NoErrorHandling() throws Exception { - doTestConnectionClosedWhenFailedUpgrade(httpClient -> httpClient, null); - } - - @Test - void testConnectionClosedWhenFailedUpgrade_ClientErrorHandling() throws Exception { - AtomicReference error = new AtomicReference<>(); - doTestConnectionClosedWhenFailedUpgrade( - httpClient -> httpClient.doOnRequestError((req, t) -> error.set(t)), null); - assertThat(error.get()).isNotNull() - .isInstanceOf(WebSocketClientHandshakeException.class); - assertThat(((WebSocketClientHandshakeException) error.get()).response().status()) - .isEqualTo(HttpResponseStatus.NOT_FOUND); - } - - @Test - void testConnectionClosedWhenFailedUpgrade_PublisherErrorHandling() throws Exception { - AtomicReference error = new AtomicReference<>(); - doTestConnectionClosedWhenFailedUpgrade(httpClient -> httpClient, error::set); - assertThat(error.get()).isNotNull() - .isInstanceOf(WebSocketClientHandshakeException.class); - assertThat(((WebSocketClientHandshakeException) error.get()).response().status()) - .isEqualTo(HttpResponseStatus.NOT_FOUND); - } - - private void doTestConnectionClosedWhenFailedUpgrade( - Function clientCustomizer, + void doTestConnectionClosedWhenFailedUpgrade(HttpServer server, HttpClient client, @Nullable Consumer errorConsumer) throws Exception { disposableServer = - createServer() - .handle((req, res) -> res.sendNotFound()) - .bindNow(); + server.handle((req, res) -> res.sendNotFound()) + .bindNow(); CountDownLatch latch = new CountDownLatch(1); - HttpClient client = - createClient(disposableServer.port()) - .doOnConnected(conn -> conn.channel().closeFuture().addListener(f -> latch.countDown())); - clientCustomizer.apply(client) - .websocket() - .uri("/") - .connect() - .subscribe(null, errorConsumer, null); + client.doOnRequest((req, conn) -> conn.channel().closeFuture().addListener(f -> latch.countDown())) + .websocket() + .uri("/") + .connect() + .subscribe(null, errorConsumer, null); assertThat(latch.await(5, TimeUnit.SECONDS)).as("latch await").isTrue(); } - @ParameterizedTest - @MethodSource("http11CompatibleProtocols") - @SuppressWarnings("deprecation") - public void testIssue3036(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, - @Nullable SslProvider.ProtocolSslContextSpec serverCtx, @Nullable SslProvider.ProtocolSslContextSpec clientCtx) { - WebsocketServerSpec websocketServerSpec = WebsocketServerSpec.builder().compress(true).build(); - - HttpServer httpServer = createServer().protocol(serverProtocols); - if (serverCtx != null) { - httpServer = httpServer.secure(spec -> spec.sslContext(serverCtx)); - } - - disposableServer = - httpServer.handle((req, res) -> res.sendWebsocket((in, out) -> out.sendString(Mono.just("test")), websocketServerSpec)) - .bindNow(); - - WebsocketClientSpec webSocketClientSpec = WebsocketClientSpec.builder().compress(true).build(); - - HttpClient httpClient = createClient(disposableServer::address).protocol(clientProtocols); - if (clientCtx != null) { - httpClient = httpClient.secure(spec -> spec.sslContext(clientCtx)); - } - - AtomicReference> responseHeaders = new AtomicReference<>(new ArrayList<>()); - httpClient.websocket(webSocketClientSpec) - .handle((in, out) -> { - responseHeaders.set(in.headers().getAll(HttpHeaderNames.SEC_WEBSOCKET_EXTENSIONS)); - return out.sendClose(); - }) - .then() - .block(Duration.ofSeconds(5)); - - assertThat(responseHeaders.get()).contains("permessage-deflate"); - } - - @Test - void testIssue3295() throws Exception { + void doTestIssue3295(HttpServer server, HttpClient client) throws Exception { AtomicReference serverError = new AtomicReference<>(); CountDownLatch serverLatch = new CountDownLatch(1); disposableServer = - createServer() - .handle((req, res) -> res.sendWebsocket((in, out) -> - in.aggregateFrames(10) - .receiveFrames() - .doOnError(t -> { - serverError.set(t); - serverLatch.countDown(); - }) - .cast(BinaryWebSocketFrame.class) - .map(DefaultByteBufHolder::content) - .then())) - .bindNow(); + server.handle((req, res) -> res.sendWebsocket((in, out) -> + in.aggregateFrames(10) + .receiveFrames() + .doOnError(t -> { + serverError.set(t); + serverLatch.countDown(); + }) + .cast(BinaryWebSocketFrame.class) + .map(DefaultByteBufHolder::content) + .then())) + .bindNow(); AtomicReference clientStatus = new AtomicReference<>(); AtomicReference connection = new AtomicReference<>(); - CountDownLatch clientLatch = new CountDownLatch(1); + CountDownLatch clientLatch = new CountDownLatch(2); byte[] content1 = "Content1".getBytes(CharsetUtil.UTF_8); byte[] content2 = "Content2".getBytes(CharsetUtil.UTF_8); byte[] content3 = "Content3".getBytes(CharsetUtil.UTF_8); - createClient(disposableServer.port()) - .websocket() - .handle((in, out) -> { - in.withConnection(connection::set); - in.receiveCloseStatus().subscribe(s -> { - clientStatus.set(s); - clientLatch.countDown(); - }); - return out.sendObject(Flux.just( - new BinaryWebSocketFrame(false, 0, Unpooled.wrappedBuffer(content1)), - new ContinuationWebSocketFrame(false, 0, Unpooled.wrappedBuffer(content2)), - new ContinuationWebSocketFrame(true, 0, Unpooled.wrappedBuffer(content3)))); - }) - .then() - .block(Duration.ofSeconds(5)); + client.websocket() + .handle((in, out) -> { + in.withConnection(conn -> { + connection.set(conn); + conn.channel().closeFuture().addListener(f -> clientLatch.countDown()); + }); + in.receiveCloseStatus().subscribe(s -> { + clientStatus.set(s); + clientLatch.countDown(); + }); + return out.sendObject(Flux.just( + new BinaryWebSocketFrame(false, 0, Unpooled.wrappedBuffer(content1)), + new ContinuationWebSocketFrame(false, 0, Unpooled.wrappedBuffer(content2)), + new ContinuationWebSocketFrame(true, 0, Unpooled.wrappedBuffer(content3)))); + }) + .then() + .block(Duration.ofSeconds(5)); assertThat(serverLatch.await(5, TimeUnit.SECONDS)).isTrue(); assertThat(serverError.get()).isNotNull().isInstanceOf(TooLongFrameException.class); @@ -1572,15 +1245,4 @@ void testIssue3295() throws Exception { assertThat(connection.get()).isNotNull(); assertThat(connection.get().channel().isActive()).isFalse(); } - - static Stream http11CompatibleProtocols() { - return Stream.of( - Arguments.of(new HttpProtocol[]{HttpProtocol.HTTP11}, new HttpProtocol[]{HttpProtocol.HTTP11}, null, null), - Arguments.of(new HttpProtocol[]{HttpProtocol.HTTP11}, new HttpProtocol[]{HttpProtocol.HTTP11}, - Named.of("Http11SslContextSpec", serverCtx11), Named.of("Http11SslContextSpec", clientCtx11)), - Arguments.of(new HttpProtocol[]{HttpProtocol.H2, HttpProtocol.HTTP11}, new HttpProtocol[]{HttpProtocol.HTTP11}, - Named.of("Http2SslContextSpec", serverCtx2), Named.of("Http11SslContextSpec", clientCtx11)), - Arguments.of(new HttpProtocol[]{HttpProtocol.H2C, HttpProtocol.HTTP11}, new HttpProtocol[]{HttpProtocol.HTTP11}, null, null) - ); - } } From 29104f4c95b470211adca4713b9d4adad41949c8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Apr 2025 14:17:53 +0300 Subject: [PATCH 23/44] Bump ruby/setup-ruby from 1.228.0 to 1.229.0 (#3699) Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.228.0 to 1.229.0. - [Release notes](https://github.com/ruby/setup-ruby/releases) - [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb) - [Commits](https://github.com/ruby/setup-ruby/compare/7886c6653556e1164c58a7603d88286b5f708293...354a1ad156761f5ee2b7b13fa8e09943a5e8d252) --- updated-dependencies: - dependency-name: ruby/setup-ruby dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5583aae3a0..8dd41119f2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -92,7 +92,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Set up Ruby for asciidoctor-pdf - uses: ruby/setup-ruby@7886c6653556e1164c58a7603d88286b5f708293 # v1 + uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 # v1 with: ruby-version: 3.3.0 - name: Install asciidoctor-pdf / rouge From b2aa821aab586389ba81da9f68b5683f40357bb6 Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Thu, 3 Apr 2025 14:33:49 +0300 Subject: [PATCH 24/44] Update dependabot ignore configuration Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .github/dependabot.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8807946e2b..622b45787b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -146,6 +146,7 @@ updates: - dependency-name: org.awaitility:awaitility - dependency-name: com.aayushatharva.brotli4j:brotli4j - dependency-name: io.netty.incubator:netty-incubator-transport-native-io_uring + - dependency-name: io.netty.incubator:netty-incubator-codec-http3 - dependency-name: com.diffplug.spotless rebase-strategy: disabled - package-ecosystem: github-actions From 9154f8f970e278df132395f8eb85c6d499c269c0 Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Thu, 3 Apr 2025 14:38:58 +0300 Subject: [PATCH 25/44] Dependabot ignore configuration has to be applied for main branch only Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .github/dependabot.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 622b45787b..8807946e2b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -146,7 +146,6 @@ updates: - dependency-name: org.awaitility:awaitility - dependency-name: com.aayushatharva.brotli4j:brotli4j - dependency-name: io.netty.incubator:netty-incubator-transport-native-io_uring - - dependency-name: io.netty.incubator:netty-incubator-codec-http3 - dependency-name: com.diffplug.spotless rebase-strategy: disabled - package-ecosystem: github-actions From 9d423efb5de2bd2d3ea25dc109f654d0017633e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Apr 2025 15:50:01 +0300 Subject: [PATCH 26/44] Bump io.netty.incubator:netty-incubator-codec-http3 (#3697) Bumps [io.netty.incubator:netty-incubator-codec-http3](https://github.com/netty/netty-incubator-codec-http3) from 0.0.28.Final to 0.0.29.Final. - [Commits](https://github.com/netty/netty-incubator-codec-http3/compare/netty-incubator-codec-http3-0.0.28.Final...netty-incubator-codec-http3-0.0.29.Final) --- updated-dependencies: - dependency-name: io.netty.incubator:netty-incubator-codec-http3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index b47b1be8f4..6e42edeb25 100644 --- a/build.gradle +++ b/build.gradle @@ -119,7 +119,7 @@ ext { } nettyIoUringVersion = '0.0.26.Final' nettyQuicVersion = '0.0.70.Final' - nettyHttp3Version = '0.0.28.Final' + nettyHttp3Version = '0.0.29.Final' // Testing brotli4jVersion = '1.18.0' From cd3a9f44fcf6da79c054bfcbfae3fdf8390b39cf Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Thu, 3 Apr 2025 17:42:55 +0300 Subject: [PATCH 27/44] Depend on Netty QUIC Codec v0.0.71.Final (#3703) Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index ec371a0f00..88a62f29b8 100644 --- a/build.gradle +++ b/build.gradle @@ -118,7 +118,7 @@ ext { println "Netty version defined from command line: ${forceNettyVersion}" } nettyIoUringVersion = '0.0.26.Final' - nettyQuicVersion = '0.0.70.Final' + nettyQuicVersion = '0.0.71.Final' // Testing brotli4jVersion = '1.18.0' From 2e2a392a9299b18488062b8994e464d0804aa0a0 Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Fri, 4 Apr 2025 09:17:49 +0300 Subject: [PATCH 28/44] [test] Increase the tests' timeout (#3704) Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .../reactor/netty/http/client/Http2WebsocketTest.java | 4 ++-- .../java/reactor/netty/http/client/WebsocketTest.java | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/Http2WebsocketTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/Http2WebsocketTest.java index 9e063ac155..d7d01c2145 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/client/Http2WebsocketTest.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/Http2WebsocketTest.java @@ -612,9 +612,9 @@ void websocketOverH2Negative(HttpServer server, HttpClient client, .collectList() .as(StepVerifier::create) .expectErrorMatches(predicate) - .verify(Duration.ofSeconds(5)); + .verify(Duration.ofSeconds(30)); - assertThat(connClosed.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(connClosed.await(30, TimeUnit.SECONDS)).isTrue(); if (serverError != null) { assertThat(serverThrowable.get()) diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/WebsocketTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/WebsocketTest.java index bb1ade2ab1..541e1db210 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/client/WebsocketTest.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/WebsocketTest.java @@ -166,7 +166,7 @@ void doUnidirectional(HttpServer server, HttpClient client) { .log()) .expectNextSequence(expected) .expectComplete() - .verify(Duration.ofSeconds(5)); + .verify(Duration.ofSeconds(30)); } void doWebSocketRespondsToRequestsFromClients(HttpServer server, HttpClient client) { @@ -215,7 +215,7 @@ void doWebSocketRespondsToRequestsFromClients(HttpServer server, HttpClient clie StepVerifier.create(response) .expectNextMatches(list -> "1000!".equals(list.get(999))) .expectComplete() - .verify(Duration.ofSeconds(5)); + .verify(Duration.ofSeconds(30)); log.debug("FINISHED: server[" + serverRes.get() + "] / client[" + clientRes + "]"); } @@ -246,7 +246,7 @@ void doUnidirectionalBinary(HttpServer server, HttpClient client) { .log()) .expectNextSequence(expected) .expectComplete() - .verify(Duration.ofSeconds(5)); + .verify(Duration.ofSeconds(30)); } void doDuplexEcho(HttpServer server, HttpClient client) throws Exception { @@ -511,7 +511,7 @@ void doClosePool(HttpServer server, HttpClient client) { .log())) .expectNextSequence(expected) .expectComplete() - .verify(Duration.ofSeconds(5)); + .verify(Duration.ofSeconds(30)); pr.dispose(); } From d83d2d80aa95169bd0c0d48fe28454f08bf042db Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Mon, 7 Apr 2025 19:09:51 +0300 Subject: [PATCH 29/44] [test] Increase the test's timeout Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .../src/test/java/reactor/netty/tcp/TcpClientTests.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/reactor-netty-core/src/test/java/reactor/netty/tcp/TcpClientTests.java b/reactor-netty-core/src/test/java/reactor/netty/tcp/TcpClientTests.java index ded3fbcd83..1933bfc7c7 100644 --- a/reactor-netty-core/src/test/java/reactor/netty/tcp/TcpClientTests.java +++ b/reactor-netty-core/src/test/java/reactor/netty/tcp/TcpClientTests.java @@ -1624,9 +1624,9 @@ private static void doTestSelectedIps( } else { result.as(StepVerifier::create) - .expectNextMatches(s -> s.startsWith("testSelectedIps")) + .expectNextMatches(s -> s.startsWith("test")) .expectComplete() - .verify(Duration.ofSeconds(5)); + .verify(Duration.ofSeconds(30)); } } finally { From 3d01c5654792fb2117a3231e8e8df3208a0db9cd Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Mon, 7 Apr 2025 21:15:24 +0300 Subject: [PATCH 30/44] [test] Fix the expectation Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .../java/reactor/netty/http/server/HttpServerTests.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/server/HttpServerTests.java b/reactor-netty-http/src/test/java/reactor/netty/http/server/HttpServerTests.java index 666e51aa56..56a0e166d2 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/server/HttpServerTests.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/server/HttpServerTests.java @@ -3531,9 +3531,13 @@ void testHttpServerCancelled() throws InterruptedException { public void channelRead(@NotNull ChannelHandlerContext ctx, @NotNull Object msg) { ByteBuf buf = (msg instanceof ByteBufHolder) ? ((ByteBufHolder) msg).content() : ((msg instanceof ByteBuf) ? (ByteBuf) msg : null); + int expectedRefCount = 0; + if (buf != null) { + expectedRefCount = buf.refCnt() -1; + } ctx.fireChannelRead(msg); // At this point, the message has been handled and must have been released. - if (buf != null && buf.refCnt() == 0) { + if (buf != null && buf.refCnt() == expectedRefCount) { serverInboundReleased.countDown(); log.debug("Server handled received message, which is now released"); } From 7229a1402a168efd94130ad2586f2efd48cac21b Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Mon, 7 Apr 2025 21:19:08 +0300 Subject: [PATCH 31/44] [test] Fix checkstyle Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .../test/java/reactor/netty/http/server/HttpServerTests.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/server/HttpServerTests.java b/reactor-netty-http/src/test/java/reactor/netty/http/server/HttpServerTests.java index 56a0e166d2..7c250e25ee 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/server/HttpServerTests.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/server/HttpServerTests.java @@ -3533,7 +3533,7 @@ public void channelRead(@NotNull ChannelHandlerContext ctx, @NotNull Object msg) ((msg instanceof ByteBuf) ? (ByteBuf) msg : null); int expectedRefCount = 0; if (buf != null) { - expectedRefCount = buf.refCnt() -1; + expectedRefCount = buf.refCnt() - 1; } ctx.fireChannelRead(msg); // At this point, the message has been handled and must have been released. From cdfda68d960f73e53214479ab04e00aa713b365d Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Tue, 8 Apr 2025 19:03:06 +0300 Subject: [PATCH 32/44] [test] Fix flaky test Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .../netty/http/client/HttpClientTest.java | 29 +++++-------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java index 692348b40c..d6907950cd 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java @@ -106,8 +106,6 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; -import org.mockito.ArgumentCaptor; -import org.mockito.Mockito; import org.reactivestreams.Publisher; import org.reactivestreams.Subscriber; @@ -155,7 +153,6 @@ import static org.assertj.core.api.Assertions.assertThatExceptionOfType; import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; import static org.assertj.core.api.Assumptions.assumeThat; -import static org.mockito.Mockito.times; /** * This test class verifies {@link HttpClient}. @@ -624,17 +621,15 @@ void sslExchangeRelativeGet() throws SSLException { @ParameterizedTest @ValueSource(booleans = {true, false}) - void testMaxConnectionPools(boolean withMaxConnectionPools) throws SSLException { - Logger spyLogger = Mockito.spy(log); - Loggers.useCustomLoggers(s -> spyLogger); - + void testMaxConnectionPools(boolean withMaxConnectionPools) throws Exception { ConnectionProvider connectionProvider = withMaxConnectionPools ? ConnectionProvider.builder("max-connection-pools").maxConnectionPools(1).build() : ConnectionProvider.builder("max-connection-pools").build(); - try { - ArgumentCaptor argumentCaptor = ArgumentCaptor.forClass(String.class); - + String msg = "Connection pool creation limit exceeded: 2 pools created, maximum expected is 1"; + String loggerName = "reactor.netty.resources.PooledConnectionProvider"; + int count = withMaxConnectionPools ? 1 : 0; + try (LogTracker logTracker = new LogTracker(loggerName, count, msg)) { SslContext sslServer = SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey()).build(); disposableServer = @@ -656,20 +651,10 @@ void testMaxConnectionPools(boolean withMaxConnectionPools) throws SSLException .expectComplete() .verify(Duration.ofSeconds(5)); - if (withMaxConnectionPools) { - Mockito.verify(spyLogger) - .warn(argumentCaptor.capture(), Mockito.eq(2), Mockito.eq(1)); - assertThat(argumentCaptor.getValue()) - .isEqualTo("Connection pool creation limit exceeded: {} pools created, maximum expected is {}"); - } - else { - Mockito.verify(spyLogger, times(0)) - .warn(Mockito.eq("Connection pool creation limit exceeded: {} pools created, maximum expected is {}"), - Mockito.eq(2), Mockito.eq(1)); - } + assertThat(logTracker.latch.await(5, TimeUnit.SECONDS)).isTrue(); + assertThat(logTracker.actualMessages).hasSize(count); } finally { - Loggers.resetLoggerFactory(); connectionProvider.dispose(); } } From bda67e048165a45031f2c08b0edb7c1d511f62fd Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Wed, 9 Apr 2025 13:05:22 +0300 Subject: [PATCH 33/44] Reduce visibility Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .../main/java/reactor/netty/http/Http2SettingsSpec.java | 5 ----- .../java/reactor/netty/http/client/HttpClientConnect.java | 2 +- .../reactor/netty/http/client/HttpTrafficHandler.java | 4 +++- .../java/reactor/netty/http/server/HttpServerConfig.java | 8 +++++--- 4 files changed, 9 insertions(+), 10 deletions(-) diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/Http2SettingsSpec.java b/reactor-netty-http/src/main/java/reactor/netty/http/Http2SettingsSpec.java index 012b34a0b4..f09ab288da 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/Http2SettingsSpec.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/Http2SettingsSpec.java @@ -229,11 +229,6 @@ public int hashCode() { return result; } - // https://datatracker.ietf.org/doc/html/rfc8441#section-9.1 - public static final char SETTINGS_ENABLE_CONNECT_PROTOCOL = 8; - public static final Long FALSE = 0L; - public static final Long TRUE = 1L; - final Boolean connectProtocolEnabled; final Long headerTableSize; final Integer initialWindowSize; diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java index da3f86b7cd..3baab80283 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpClientConnect.java @@ -67,7 +67,6 @@ import reactor.util.retry.Retry; import static reactor.netty.ReactorNetty.format; -import static reactor.netty.http.Http2SettingsSpec.FALSE; import static reactor.netty.http.client.HttpClientOperations.H2; import static reactor.netty.http.client.HttpClientState.STREAM_CONFIGURED; @@ -464,6 +463,7 @@ public void onStateChange(Connection connection, State newState) { static final class HttpClientHandler extends SocketAddress implements Predicate, Supplier { + static final Long FALSE = 0L; volatile HttpMethod method; final HttpHeaders defaultHeaders; diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpTrafficHandler.java b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpTrafficHandler.java index 2e288b4892..dcb58b81cb 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpTrafficHandler.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/client/HttpTrafficHandler.java @@ -30,7 +30,6 @@ import static io.netty.handler.codec.http.HttpClientUpgradeHandler.UpgradeEvent.UPGRADE_REJECTED; import static io.netty.handler.codec.http.HttpClientUpgradeHandler.UpgradeEvent.UPGRADE_SUCCESSFUL; import static reactor.netty.ReactorNetty.format; -import static reactor.netty.http.Http2SettingsSpec.SETTINGS_ENABLE_CONNECT_PROTOCOL; import static reactor.netty.http.client.HttpClientConnect.ENABLE_CONNECT_PROTOCOL; /** @@ -127,4 +126,7 @@ void sendNewState(Connection connection, ConnectionObserver.State state) { } static final Logger log = Loggers.getLogger(HttpTrafficHandler.class); + + // https://datatracker.ietf.org/doc/html/rfc8441#section-9.1 + static final char SETTINGS_ENABLE_CONNECT_PROTOCOL = 8; } diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerConfig.java b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerConfig.java index d729c673e6..389770a112 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerConfig.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerConfig.java @@ -99,9 +99,6 @@ import static reactor.netty.NettyPipeline.LEFT; import static reactor.netty.ReactorNetty.ACCESS_LOG_ENABLED; import static reactor.netty.ReactorNetty.format; -import static reactor.netty.http.Http2SettingsSpec.FALSE; -import static reactor.netty.http.Http2SettingsSpec.SETTINGS_ENABLE_CONNECT_PROTOCOL; -import static reactor.netty.http.Http2SettingsSpec.TRUE; import static reactor.netty.http.server.Http3Codec.newHttp3ServerConnectionHandler; import static reactor.netty.http.server.HttpServerFormDecoderProvider.DEFAULT_FORM_DECODER_SPEC; @@ -908,6 +905,11 @@ else if (metricsRecorder instanceof ContextAwareHttpServerMetricsRecorder) { */ static final boolean SSL_DEBUG = Boolean.parseBoolean(System.getProperty(ReactorNetty.SSL_SERVER_DEBUG, "false")); + // https://datatracker.ietf.org/doc/html/rfc8441#section-9.1 + static final char SETTINGS_ENABLE_CONNECT_PROTOCOL = 8; + static final Long FALSE = 0L; + static final Long TRUE = 1L; + static final class H2ChannelMetricsHandler extends AbstractChannelMetricsHandler { final ChannelMetricsRecorder recorder; From d8409e32dd1b060c2c47f480be7dce92a954f5e6 Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Wed, 9 Apr 2025 17:52:15 +0300 Subject: [PATCH 34/44] [test] Fix flaky test Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .../test/java/reactor/netty/http/client/HttpClientTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java index 9109fdff34..69d8c18de4 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/HttpClientTest.java @@ -3528,6 +3528,9 @@ void testIssue3285(String serverResponse, @Nullable Class e try (LogTracker logTracker = new LogTracker("reactor.netty.channel.ChannelOperationsHandler", 2, "Decoding failed.")) { testIssue3285SendRequest(client, expectedException); + // Delay a bit the next request so that we do not acquire immediately + Thread.sleep(100); + testIssue3285SendRequest(client, expectedException); assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue(); From 98698ce46bd104138381324d0584c08450a844ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Apr 2025 08:42:38 +0300 Subject: [PATCH 35/44] Bump org.apache.tomcat.embed:tomcat-embed-core from 9.0.102 to 9.0.104 (#3710) Bumps org.apache.tomcat.embed:tomcat-embed-core from 9.0.102 to 9.0.104. --- updated-dependencies: - dependency-name: org.apache.tomcat.embed:tomcat-embed-core dependency-version: 9.0.104 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 88a62f29b8..107719a3ca 100644 --- a/build.gradle +++ b/build.gradle @@ -127,7 +127,7 @@ ext { assertJVersion = '3.27.3' awaitilityVersion = '4.3.0' hoverflyJavaVersion = '0.19.1' - tomcatVersion = '9.0.102' + tomcatVersion = '9.0.104' boringSslVersion = '2.0.70.Final' junitVersion = '5.12.1' junitPlatformLauncherVersion = '1.12.1' From 93802cac0a1b52537b5877e6ecb286a8a66bfe2f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 10 Apr 2025 08:47:04 +0300 Subject: [PATCH 36/44] Bump actions/setup-java from 4.7.0 to 4.7.1 (#3711) Bumps [actions/setup-java](https://github.com/actions/setup-java) from 4.7.0 to 4.7.1. - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/3a4f6e1af504cf6a31855fa899c6aa5355ba6c12...c5195efecf7bdfc987ee8bae7a71cb8b11521c00) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: 4.7.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/check_graalvm.yml | 4 ++-- .github/workflows/check_netty_snapshots.yml | 2 +- .../workflows/check_reactor_core_3.6_snapshots.yml | 2 +- .github/workflows/check_transport.yml | 4 ++-- .github/workflows/codeql-analysis.yml | 2 +- .github/workflows/publish.yml | 14 +++++++------- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.github/workflows/check_graalvm.yml b/.github/workflows/check_graalvm.yml index 08774d4fda..d90a765477 100644 --- a/.github/workflows/check_graalvm.yml +++ b/.github/workflows/check_graalvm.yml @@ -20,12 +20,12 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Set up JDK 1.8 - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: distribution: 'temurin' java-version: '8' - name: Set up GraalVM 17 - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: distribution: 'graalvm' java-version: '17.0.12' diff --git a/.github/workflows/check_netty_snapshots.yml b/.github/workflows/check_netty_snapshots.yml index 50f7f7de92..7fe45bf69a 100644 --- a/.github/workflows/check_netty_snapshots.yml +++ b/.github/workflows/check_netty_snapshots.yml @@ -24,7 +24,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Set up JDK 1.8 - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: distribution: 'temurin' java-version: '8' diff --git a/.github/workflows/check_reactor_core_3.6_snapshots.yml b/.github/workflows/check_reactor_core_3.6_snapshots.yml index 065194d1df..3f7a22f964 100644 --- a/.github/workflows/check_reactor_core_3.6_snapshots.yml +++ b/.github/workflows/check_reactor_core_3.6_snapshots.yml @@ -14,7 +14,7 @@ jobs: with: ref: '1.1.x' - name: Set up JDK 1.8 - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: distribution: 'temurin' java-version: '8' diff --git a/.github/workflows/check_transport.yml b/.github/workflows/check_transport.yml index 75586f6bb2..6f034c5fc1 100644 --- a/.github/workflows/check_transport.yml +++ b/.github/workflows/check_transport.yml @@ -11,7 +11,7 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 with: fetch-depth: 0 #needed by spotless - - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: distribution: 'temurin' java-version: 8 @@ -53,7 +53,7 @@ jobs: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: gradle/actions/wrapper-validation@06832c7b30a0129d7fb559bcc6e43d26f6374244 - name: Set up JDK 1.8 - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: distribution: 'temurin' java-version: '8' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 82fd4bce04..e102742c12 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -55,7 +55,7 @@ jobs: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: setup java - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: distribution: 'temurin' java-version: '8' diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f8ad3e20d7..8390b01aa9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: setup java - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: distribution: 'temurin' java-version: '8' @@ -39,7 +39,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: setup java - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: distribution: 'temurin' java-version: '8' @@ -53,7 +53,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: setup java - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: distribution: 'temurin' java-version: '8' @@ -67,7 +67,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: setup java - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: distribution: 'temurin' java-version: '8' @@ -84,7 +84,7 @@ jobs: environment: snapshots steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: distribution: 'temurin' java-version: '8' @@ -104,7 +104,7 @@ jobs: environment: releases steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: distribution: 'temurin' java-version: '8' @@ -126,7 +126,7 @@ jobs: environment: releases steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - uses: actions/setup-java@3a4f6e1af504cf6a31855fa899c6aa5355ba6c12 + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 with: distribution: 'temurin' java-version: '8' From ca448bc0f4b79da32008c92512c41e6408787ac3 Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Thu, 10 Apr 2025 20:18:25 +0300 Subject: [PATCH 37/44] Bump Gradle to version 8.13 (#3713) According to the documentation the command below should be used for upgrade ./gradlew wrapper --gradle-version 8.13 Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 43705 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b9530d66f5e68d973ea569d8e19de379189..9bbc975c742b298b441bfb90dbc124400a3751b9 100644 GIT binary patch delta 34744 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 delta 34727 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�{ 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 a%EQ2*fnt2> Date: Fri, 11 Apr 2025 15:16:56 +0300 Subject: [PATCH 38/44] Support HttpServerRoutes#ws with HTTP/2 (#3715) Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- .../netty/http/server/Http2WebsocketServerOperations.java | 2 +- .../java/reactor/netty/http/server/HttpServerConfig.java | 3 ++- .../reactor/netty/http/server/HttpServerOperations.java | 7 +++++++ .../java/reactor/netty/http/server/HttpServerRoutes.java | 7 +++++-- .../java/reactor/netty/http/client/Http2WebsocketTest.java | 2 -- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/server/Http2WebsocketServerOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/server/Http2WebsocketServerOperations.java index f0c40cd32e..41dffa8564 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/server/Http2WebsocketServerOperations.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/server/Http2WebsocketServerOperations.java @@ -175,7 +175,7 @@ boolean isValid() { else if (!CONNECT.equals(method())) { msg = "Invalid websocket request handshake method [" + method() + "]."; } - else if (!requestHeaders().contains("x-protocol", HttpHeaderValues.WEBSOCKET, true)) { + else if (!requestHeaders().contains("x-http2-protocol", HttpHeaderValues.WEBSOCKET, true)) { msg = "Invalid websocket request, missing [:protocol=websocket] header."; } else { diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerConfig.java b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerConfig.java index 389770a112..eb48ce095d 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerConfig.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerConfig.java @@ -1600,7 +1600,8 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception Http2Headers headers = ((Http2HeadersFrame) msg).headers(); CharSequence value = headers.get(Http2Headers.PseudoHeaderName.PROTOCOL.value()); if (value != null) { - headers.set("x-protocol", value); + headers.set("x-http2-protocol", value); + headers.set("x-http2-path", headers.path()); } ctx.pipeline().remove(this); } diff --git a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerOperations.java b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerOperations.java index 7f62d98268..826c097300 100644 --- a/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerOperations.java +++ b/reactor-netty-http/src/main/java/reactor/netty/http/server/HttpServerOperations.java @@ -207,6 +207,13 @@ class HttpServerOperations extends HttpOperations> handler, WebsocketServerSpec configurer) { - return ws(HttpPredicate.get(path), handler, configurer); + return ws(HttpPredicate.get(path), handler, configurer).ws(http(path, H2, HttpMethod.CONNECT), handler, configurer); } /** @@ -345,7 +348,7 @@ default HttpServerRoutes ws(Predicate condition, HttpHeaders requestHeaders = req.requestHeaders(); HttpServerOperations ops = (HttpServerOperations) req; if (requestHeaders.containsValue(HttpHeaderNames.CONNECTION, HttpHeaderValues.UPGRADE, true) || - (ops.isHttp2 && requestHeaders.containsValue("x-protocol", HttpHeaderValues.WEBSOCKET, true))) { + (ops.isHttp2 && requestHeaders.containsValue("x-http2-protocol", HttpHeaderValues.WEBSOCKET, true))) { return ops.withWebsocketSupport(req.uri(), websocketServerSpec, handler); } return resp.sendNotFound(); diff --git a/reactor-netty-http/src/test/java/reactor/netty/http/client/Http2WebsocketTest.java b/reactor-netty-http/src/test/java/reactor/netty/http/client/Http2WebsocketTest.java index d7d01c2145..9249b8a04b 100644 --- a/reactor-netty-http/src/test/java/reactor/netty/http/client/Http2WebsocketTest.java +++ b/reactor-netty-http/src/test/java/reactor/netty/http/client/Http2WebsocketTest.java @@ -32,7 +32,6 @@ import io.netty.handler.codec.http2.DefaultHttp2HeadersFrame; import io.netty.handler.codec.http2.Http2Headers; import io.netty.handler.codec.http2.Http2HeadersFrame; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Named; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; @@ -305,7 +304,6 @@ void websocketOverH2TestIssue444_3(HttpProtocol[] serverProtocols, HttpProtocol[ @ParameterizedTest @MethodSource("http2CompatibleCombinations") - @Disabled void websocketOverH2TestIssue821(HttpProtocol[] serverProtocols, HttpProtocol[] clientProtocols, @Nullable Http2SslContextSpec serverCtx, @Nullable Http2SslContextSpec clientCtx) throws Exception { doTestIssue821(configureServer(serverProtocols, serverCtx), configureClient(clientProtocols, clientCtx)); From d62392c7e9bce582cbc14f72bbe2a9a8611bc176 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 09:52:29 +0300 Subject: [PATCH 39/44] Bump ruby/setup-ruby from 1.229.0 to 1.230.0 (#3716) Bumps [ruby/setup-ruby](https://github.com/ruby/setup-ruby) from 1.229.0 to 1.230.0. - [Release notes](https://github.com/ruby/setup-ruby/releases) - [Changelog](https://github.com/ruby/setup-ruby/blob/master/release.rb) - [Commits](https://github.com/ruby/setup-ruby/compare/354a1ad156761f5ee2b7b13fa8e09943a5e8d252...e5ac7b085f6e63d49c8973eb0c6e04d876b881f1) --- updated-dependencies: - dependency-name: ruby/setup-ruby dependency-version: 1.230.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c9c0d53d94..433129d081 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -92,7 +92,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - name: Set up Ruby for asciidoctor-pdf - uses: ruby/setup-ruby@354a1ad156761f5ee2b7b13fa8e09943a5e8d252 # v1 + uses: ruby/setup-ruby@e5ac7b085f6e63d49c8973eb0c6e04d876b881f1 # v1 with: ruby-version: 3.3.0 - name: Install asciidoctor-pdf / rouge From e2ae4e8815d4a9ca93e1c3595404f4d63ca80087 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 09:56:48 +0300 Subject: [PATCH 40/44] Bump junitVersion from 5.12.1 to 5.12.2 (#3717) Bumps `junitVersion` from 5.12.1 to 5.12.2. Updates `org.junit.jupiter:junit-jupiter-api` from 5.12.1 to 5.12.2 - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/compare/r5.12.1...r5.12.2) Updates `org.junit.jupiter:junit-jupiter-params` from 5.12.1 to 5.12.2 - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/compare/r5.12.1...r5.12.2) Updates `org.junit.jupiter:junit-jupiter-engine` from 5.12.1 to 5.12.2 - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/compare/r5.12.1...r5.12.2) --- updated-dependencies: - dependency-name: org.junit.jupiter:junit-jupiter-api dependency-version: 5.12.2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.junit.jupiter:junit-jupiter-params dependency-version: 5.12.2 dependency-type: direct:production update-type: version-update:semver-patch - dependency-name: org.junit.jupiter:junit-jupiter-engine dependency-version: 5.12.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 107719a3ca..850ba45cde 100644 --- a/build.gradle +++ b/build.gradle @@ -129,7 +129,7 @@ ext { hoverflyJavaVersion = '0.19.1' tomcatVersion = '9.0.104' boringSslVersion = '2.0.70.Final' - junitVersion = '5.12.1' + junitVersion = '5.12.2' junitPlatformLauncherVersion = '1.12.1' mockitoVersion = '4.11.0' blockHoundVersion = '1.0.11.RELEASE' From 070876f2b174940a8bc2d58440b902e80ccb920f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 10:29:31 +0300 Subject: [PATCH 41/44] Bump org.junit.platform:junit-platform-launcher from 1.12.1 to 1.12.2 (#3718) Bumps [org.junit.platform:junit-platform-launcher](https://github.com/junit-team/junit5) from 1.12.1 to 1.12.2. - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/commits) --- updated-dependencies: - dependency-name: org.junit.platform:junit-platform-launcher dependency-version: 1.12.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 850ba45cde..3830c1baf7 100644 --- a/build.gradle +++ b/build.gradle @@ -130,7 +130,7 @@ ext { tomcatVersion = '9.0.104' boringSslVersion = '2.0.70.Final' junitVersion = '5.12.2' - junitPlatformLauncherVersion = '1.12.1' + junitPlatformLauncherVersion = '1.12.2' mockitoVersion = '4.11.0' blockHoundVersion = '1.0.11.RELEASE' reflectionsVersion = '0.10.2' From 396a96c281723306ca7bdf3ad026b89846e52f14 Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Tue, 15 Apr 2025 09:46:23 +0300 Subject: [PATCH 42/44] [release] Prepare and release 1.1.29 Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- README.md | 8 ++++---- gradle.properties | 8 ++++---- reactor-netty-incubator-quic/README.md | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index ae711fe261..fa61f9147b 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,10 @@ With `Gradle` from [repo.spring.io](https://repo.spring.io) or `Maven Central` r } dependencies { - //compile "io.projectreactor.netty:reactor-netty-core:1.1.29-SNAPSHOT" - compile "io.projectreactor.netty:reactor-netty-core:1.1.28" - //compile "io.projectreactor.netty:reactor-netty-http:1.1.29-SNAPSHOT" - compile "io.projectreactor.netty:reactor-netty-http:1.1.28" + //compile "io.projectreactor.netty:reactor-netty-core:1.1.30-SNAPSHOT" + compile "io.projectreactor.netty:reactor-netty-core:1.1.29" + //compile "io.projectreactor.netty:reactor-netty-http:1.1.30-SNAPSHOT" + compile "io.projectreactor.netty:reactor-netty-http:1.1.29" } ``` diff --git a/gradle.properties b/gradle.properties index 6d735317fc..f5f58e5468 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ -reactorPoolVersion=1.0.11-SNAPSHOT -version=1.1.29-SNAPSHOT -reactorNettyQuicVersion=0.1.29-SNAPSHOT +reactorPoolVersion=1.0.10 +version=1.1.29 +reactorNettyQuicVersion=0.1.29 reactorCoreVersion=3.5.20 -reactorAddonsVersion=3.5.3-SNAPSHOT +reactorAddonsVersion=3.5.2 compatibleVersion=1.1.28 bomVersion=2022.0.22 diff --git a/reactor-netty-incubator-quic/README.md b/reactor-netty-incubator-quic/README.md index 6f69522204..c9880e6ce9 100644 --- a/reactor-netty-incubator-quic/README.md +++ b/reactor-netty-incubator-quic/README.md @@ -13,8 +13,8 @@ With `Gradle` from [repo.spring.io](https://repo.spring.io) or `Maven Central` r } dependencies { - //compile "io.projectreactor.netty.incubator:reactor-netty-incubator-quic:0.1.29-SNAPSHOT" - compile "io.projectreactor.netty.incubator:reactor-netty-incubator-quic:0.1.28" + //compile "io.projectreactor.netty.incubator:reactor-netty-incubator-quic:0.1.30-SNAPSHOT" + compile "io.projectreactor.netty.incubator:reactor-netty-incubator-quic:0.1.29" } ``` From 9ed74081bf035672724c53291c1416802937d327 Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Tue, 15 Apr 2025 10:28:24 +0300 Subject: [PATCH 43/44] [release] Back to snapshots, next is 1.1.30-SNAPSHOT Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- gradle.properties | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/gradle.properties b/gradle.properties index f5f58e5468..a71f0fc8db 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ -reactorPoolVersion=1.0.10 -version=1.1.29 -reactorNettyQuicVersion=0.1.29 +reactorPoolVersion=1.0.11-SNAPSHOT +version=1.1.30-SNAPSHOT +reactorNettyQuicVersion=0.1.30-SNAPSHOT reactorCoreVersion=3.5.20 -reactorAddonsVersion=3.5.2 -compatibleVersion=1.1.28 +reactorAddonsVersion=3.5.3-SNAPSHOT +compatibleVersion=1.1.29 bomVersion=2022.0.22 From 2a833f965ccc17582fd306393826fb2de18f8888 Mon Sep 17 00:00:00 2001 From: Violeta Georgieva <696661+violetagg@users.noreply.github.com> Date: Tue, 15 Apr 2025 11:00:27 +0300 Subject: [PATCH 44/44] [release] Prepare and release 1.2.5 Signed-off-by: Violeta Georgieva <696661+violetagg@users.noreply.github.com> --- README.md | 8 ++++---- gradle.properties | 12 ++++++------ reactor-netty-incubator-quic/README.md | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 254e0aac77..0faf57e104 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,10 @@ With `Gradle` from [repo.spring.io](https://repo.spring.io) or `Maven Central` r } dependencies { - //compile "io.projectreactor.netty:reactor-netty-core:1.2.5-SNAPSHOT" - compile "io.projectreactor.netty:reactor-netty-core:1.2.4" - //compile "io.projectreactor.netty:reactor-netty-http:1.2.5-SNAPSHOT" - compile "io.projectreactor.netty:reactor-netty-http:1.2.4" + //compile "io.projectreactor.netty:reactor-netty-core:1.2.6-SNAPSHOT" + compile "io.projectreactor.netty:reactor-netty-core:1.2.5" + //compile "io.projectreactor.netty:reactor-netty-http:1.2.6-SNAPSHOT" + compile "io.projectreactor.netty:reactor-netty-http:1.2.5" } ``` diff --git a/gradle.properties b/gradle.properties index b544c2c18f..39e2332a0c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ -reactorPoolVersion=1.1.3-SNAPSHOT -version=1.2.5-SNAPSHOT -reactorNettyQuicVersion=0.2.5-SNAPSHOT -reactorCoreVersion=3.7.5-SNAPSHOT -reactorAddonsVersion=3.5.3-SNAPSHOT +reactorPoolVersion=1.1.2 +version=1.2.5 +reactorNettyQuicVersion=0.2.5 +reactorCoreVersion=3.7.5 +reactorAddonsVersion=3.5.2 compatibleVersion=1.2.4 -bomVersion=2024.0.4 +bomVersion=2024.0.5 diff --git a/reactor-netty-incubator-quic/README.md b/reactor-netty-incubator-quic/README.md index 538d13242a..1cee9bec31 100644 --- a/reactor-netty-incubator-quic/README.md +++ b/reactor-netty-incubator-quic/README.md @@ -13,8 +13,8 @@ With `Gradle` from [repo.spring.io](https://repo.spring.io) or `Maven Central` r } dependencies { - //compile "io.projectreactor.netty.incubator:reactor-netty-incubator-quic:0.2.5-SNAPSHOT" - compile "io.projectreactor.netty.incubator:reactor-netty-incubator-quic:0.2.4" + //compile "io.projectreactor.netty.incubator:reactor-netty-incubator-quic:0.2.6-SNAPSHOT" + compile "io.projectreactor.netty.incubator:reactor-netty-incubator-quic:0.2.5" } ```