diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f377d9b..6e1f1661 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,12 +30,16 @@ jobs: matrix: os: [ubuntu-latest] scala: [2.12, 2.13, 3] - java: [temurin@11, temurin@17] + java: [temurin@11, temurin@17, temurin@21] exclude: - scala: 2.12 java: temurin@17 + - scala: 2.12 + java: temurin@21 - scala: 3 java: temurin@17 + - scala: 3 + java: temurin@21 runs-on: ${{ matrix.os }} timeout-minutes: 60 steps: @@ -73,6 +77,19 @@ jobs: if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' run: sbt +update + - name: Setup Java (temurin@21) + id: setup-java-temurin-21 + if: matrix.java == 'temurin@21' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@21' && steps.setup-java-temurin-21.outputs.cache-hit == 'false' + run: sbt +update + - name: Check that workflows are up to date run: sbt githubWorkflowCheck @@ -158,6 +175,19 @@ jobs: if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' run: sbt +update + - name: Setup Java (temurin@21) + id: setup-java-temurin-21 + if: matrix.java == 'temurin@21' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@21' && steps.setup-java-temurin-21.outputs.cache-hit == 'false' + run: sbt +update + - name: Download target directories (2.12) uses: actions/download-artifact@v4 with: @@ -255,6 +285,19 @@ jobs: if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' run: sbt +update + - name: Setup Java (temurin@21) + id: setup-java-temurin-21 + if: matrix.java == 'temurin@21' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@21' && steps.setup-java-temurin-21.outputs.cache-hit == 'false' + run: sbt +update + - name: Submit Dependencies uses: scalacenter/sbt-dependency-submission@v2 with: @@ -303,6 +346,19 @@ jobs: if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false' run: sbt +update + - name: Setup Java (temurin@21) + id: setup-java-temurin-21 + if: matrix.java == 'temurin@21' + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 21 + cache: sbt + + - name: sbt update + if: matrix.java == 'temurin@21' && steps.setup-java-temurin-21.outputs.cache-hit == 'false' + run: sbt +update + - name: Generate site run: sbt docs/tlSite diff --git a/.mergify.yml b/.mergify.yml index d97e0ec2..a2fb1382 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -13,6 +13,7 @@ pull_request_rules: - status-success=Build and Test (ubuntu-latest, 2.12, temurin@11) - status-success=Build and Test (ubuntu-latest, 2.13, temurin@11) - status-success=Build and Test (ubuntu-latest, 2.13, temurin@17) + - status-success=Build and Test (ubuntu-latest, 2.13, temurin@21) - status-success=Build and Test (ubuntu-latest, 3, temurin@11) - status-success=Generate Site (ubuntu-latest, temurin@11) actions: diff --git a/build.sbt b/build.sbt index 01e59985..a425b0fa 100644 --- a/build.sbt +++ b/build.sbt @@ -1,4 +1,5 @@ import com.typesafe.tools.mima.core._ +import explicitdeps.ExplicitDepsPlugin.autoImport.moduleFilterRemoveValue lazy val root = project .in(file(".")) @@ -71,7 +72,7 @@ val coreDeps = Seq( val scala213 = "2.13.15" ThisBuild / crossScalaVersions := Seq("2.12.20", scala213, "3.3.4") ThisBuild / scalaVersion := scala213 -ThisBuild / tlBaseVersion := "0.9" +ThisBuild / tlBaseVersion := "0.10" ThisBuild / startYear := Some(2019) ThisBuild / developers := List( tlGitHubDev("ChristopherDavenport", "Christopher Davenport"), @@ -80,7 +81,7 @@ ThisBuild / developers := List( ) ThisBuild / tlJdkRelease := Some(11) -ThisBuild / githubWorkflowJavaVersions := Seq("11", "17").map(JavaSpec.temurin(_)) +ThisBuild / githubWorkflowJavaVersions := Seq("11", "17", "21").map(JavaSpec.temurin(_)) ThisBuild / tlCiReleaseBranches := Seq("series/0.9") ThisBuild / tlSitePublishBranch := Some("series/0.9") diff --git a/core/src/main/scala/org/http4s/jdkhttpclient/JdkHttpClient.scala b/core/src/main/scala/org/http4s/jdkhttpclient/JdkHttpClient.scala index 1c0472d7..b32eb82b 100644 --- a/core/src/main/scala/org/http4s/jdkhttpclient/JdkHttpClient.scala +++ b/core/src/main/scala/org/http4s/jdkhttpclient/JdkHttpClient.scala @@ -243,9 +243,22 @@ object JdkHttpClient { * [[cats.effect.kernel.Async.executor executor]], sets the * [[org.http4s.client.defaults.ConnectTimeout default http4s connect timeout]], and disables * [[https://github.com/http4s/http4s-jdk-http-client/issues/200 TLS 1.3 on JDK 11]]. + * + * On Java 21 and higher, it actively closes the underlying client, releasing its resources + * early. On earlier Java versions, closing the underlying client is not possible, so the release + * is a no-op. On these Java versions (and there only), you can safely use + * [[cats.effect.Resource allocated]] to avoid dealing with resource management. */ - def simple[F[_]](implicit F: Async[F]): F[Client[F]] = - defaultHttpClient[F].map(apply(_)) + def simple[F[_]](implicit F: Async[F]): Resource[F, Client[F]] = + defaultHttpClientResource[F].map(apply(_)) + + private[jdkhttpclient] def defaultHttpClientResource[F[_]](implicit + F: Async[F] + ): Resource[F, HttpClient] = + Resource.make[F, HttpClient](defaultHttpClient[F]) { + case c: AutoCloseable => Sync[F].blocking(c.close()) + case _ => Applicative[F].unit + } private[jdkhttpclient] def defaultHttpClient[F[_]](implicit F: Async[F]): F[HttpClient] = F.executor.flatMap { exec => diff --git a/core/src/main/scala/org/http4s/jdkhttpclient/JdkWSClient.scala b/core/src/main/scala/org/http4s/jdkhttpclient/JdkWSClient.scala index f8d128e1..49488e2f 100644 --- a/core/src/main/scala/org/http4s/jdkhttpclient/JdkWSClient.scala +++ b/core/src/main/scala/org/http4s/jdkhttpclient/JdkWSClient.scala @@ -131,6 +131,11 @@ object JdkWSClient { }) } yield () } + // If the input side is still open (no close received from server), the JDK will not clean up the connection. + // This also implies the client can't be shutdown on Java 21+ as it waits for all open connections + // to be be closed. As we don't expect/handle anything coming on the input anymore + // at this point, we can safely abort. + _ <- F.delay(webSocket.abort()) } yield () } .map { case (webSocket, queue, closedDef, sendSem) => @@ -164,7 +169,12 @@ object JdkWSClient { * [[cats.effect.kernel.Async.executor executor]], sets the * [[org.http4s.client.defaults.ConnectTimeout default http4s connect timeout]], and disables * [[https://github.com/http4s/http4s-jdk-http-client/issues/200 TLS 1.3 on JDK 11]]. + * + * * On Java 21 and higher, it actively closes the underlying client, releasing its resources + * early. On earlier Java versions, closing the underlying client is not possible, so the release + * is a no-op. On these Java versions (and there only), you can safely use + * [[cats.effect.Resource allocated]] to avoid dealing with resource management. */ - def simple[F[_]](implicit F: Async[F]): F[WSClient[F]] = - JdkHttpClient.defaultHttpClient[F].map(apply(_)) + def simple[F[_]](implicit F: Async[F]): Resource[F, WSClient[F]] = + JdkHttpClient.defaultHttpClientResource[F].map(apply(_)) } diff --git a/core/src/test/scala/org/http4s/jdkhttpclient/BodyLeakExample.scala b/core/src/test/scala/org/http4s/jdkhttpclient/BodyLeakExample.scala index 7da879a8..b7bdad87 100644 --- a/core/src/test/scala/org/http4s/jdkhttpclient/BodyLeakExample.scala +++ b/core/src/test/scala/org/http4s/jdkhttpclient/BodyLeakExample.scala @@ -51,7 +51,7 @@ object BodyLeakExample extends IOApp { .withPort(port"8080") .withHttpApp(app) .build - .product(Resource.eval(JdkHttpClient.simple[IO])) + .product(JdkHttpClient.simple[IO]) .use { case (_, client) => for { counter <- Ref.of[IO, Long](0L) diff --git a/core/src/test/scala/org/http4s/jdkhttpclient/DeadlockWorkaround.scala b/core/src/test/scala/org/http4s/jdkhttpclient/DeadlockWorkaround.scala index e6d903bb..d471ca44 100644 --- a/core/src/test/scala/org/http4s/jdkhttpclient/DeadlockWorkaround.scala +++ b/core/src/test/scala/org/http4s/jdkhttpclient/DeadlockWorkaround.scala @@ -29,7 +29,7 @@ class DeadlockWorkaround extends CatsEffectSuite { test("fail to connect via TLSv1.3 on Java 11") { if (Runtime.version().feature() > 11) IO.pure(true) else - (JdkHttpClient.simple[IO], JdkWSClient.simple[IO]).flatMapN { (http, ws) => + (JdkHttpClient.simple[IO], JdkWSClient.simple[IO]).tupled.use { case (http, ws) => def testSSLFailure(r: IO[Unit]) = r.intercept[SSLHandshakeException] testSSLFailure(http.expect[Unit](uri"https://tls13.1d.pw")) *> testSSLFailure(ws.connectHighLevel(WSRequest(uri"wss://tls13.1d.pw")).use(_ => IO.unit)) diff --git a/core/src/test/scala/org/http4s/jdkhttpclient/JdkHttpClientSpec.scala b/core/src/test/scala/org/http4s/jdkhttpclient/JdkHttpClientSpec.scala index c7308651..77ef29b7 100644 --- a/core/src/test/scala/org/http4s/jdkhttpclient/JdkHttpClientSpec.scala +++ b/core/src/test/scala/org/http4s/jdkhttpclient/JdkHttpClientSpec.scala @@ -28,7 +28,7 @@ import org.typelevel.ci._ import scala.concurrent.duration._ class JdkHttpClientSpec extends ClientRouteTestBattery("JdkHttpClient") { - def clientResource: Resource[IO, Client[IO]] = Resource.eval(JdkHttpClient.simple[IO]) + def clientResource: Resource[IO, Client[IO]] = JdkHttpClient.simple[IO] // regression test for https://github.com/http4s/http4s-jdk-http-client/issues/395 test("Don't error with empty body and explicit Content-Length: 0") { diff --git a/core/src/test/scala/org/http4s/jdkhttpclient/JdkWSClientSpec.scala b/core/src/test/scala/org/http4s/jdkhttpclient/JdkWSClientSpec.scala index d81e63de..d6604b7f 100644 --- a/core/src/test/scala/org/http4s/jdkhttpclient/JdkWSClientSpec.scala +++ b/core/src/test/scala/org/http4s/jdkhttpclient/JdkWSClientSpec.scala @@ -36,7 +36,7 @@ import scala.concurrent.duration._ class JdkWSClientSpec extends CatsEffectSuite { val webSocket: IOFixture[WSClient[IO]] = - ResourceSuiteLocalFixture("webSocket", Resource.eval(JdkWSClient.simple[IO])) + ResourceSuiteLocalFixture("webSocket", JdkWSClient.simple[IO]) val echoServerUri: IOFixture[Uri] = ResourceSuiteLocalFixture( "echoServerUri",