Skip to content

Commit e8138e1

Browse files
authored
Merge pull request #1148 from http4s/merge-to-main
Merge series/0.10 to main
2 parents 28ee5e2 + 62d4610 commit e8138e1

File tree

10 files changed

+110
-19
lines changed

10 files changed

+110
-19
lines changed

.github/workflows/ci.yml

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,12 @@ jobs:
3030
matrix:
3131
os: [ubuntu-latest]
3232
scala: [2.13, 3]
33-
java: [temurin@11, temurin@17]
33+
java: [temurin@11, temurin@17, temurin@21]
3434
exclude:
3535
- scala: 3
3636
java: temurin@17
37+
- scala: 3
38+
java: temurin@21
3739
runs-on: ${{ matrix.os }}
3840
timeout-minutes: 60
3941
steps:
@@ -71,6 +73,19 @@ jobs:
7173
if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false'
7274
run: sbt +update
7375

76+
- name: Setup Java (temurin@21)
77+
id: setup-java-temurin-21
78+
if: matrix.java == 'temurin@21'
79+
uses: actions/setup-java@v4
80+
with:
81+
distribution: temurin
82+
java-version: 21
83+
cache: sbt
84+
85+
- name: sbt update
86+
if: matrix.java == 'temurin@21' && steps.setup-java-temurin-21.outputs.cache-hit == 'false'
87+
run: sbt +update
88+
7489
- name: Check that workflows are up to date
7590
run: sbt githubWorkflowCheck
7691

@@ -156,6 +171,19 @@ jobs:
156171
if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false'
157172
run: sbt +update
158173

174+
- name: Setup Java (temurin@21)
175+
id: setup-java-temurin-21
176+
if: matrix.java == 'temurin@21'
177+
uses: actions/setup-java@v4
178+
with:
179+
distribution: temurin
180+
java-version: 21
181+
cache: sbt
182+
183+
- name: sbt update
184+
if: matrix.java == 'temurin@21' && steps.setup-java-temurin-21.outputs.cache-hit == 'false'
185+
run: sbt +update
186+
159187
- name: Download target directories (2.13)
160188
uses: actions/download-artifact@v4
161189
with:
@@ -243,6 +271,19 @@ jobs:
243271
if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false'
244272
run: sbt +update
245273

274+
- name: Setup Java (temurin@21)
275+
id: setup-java-temurin-21
276+
if: matrix.java == 'temurin@21'
277+
uses: actions/setup-java@v4
278+
with:
279+
distribution: temurin
280+
java-version: 21
281+
cache: sbt
282+
283+
- name: sbt update
284+
if: matrix.java == 'temurin@21' && steps.setup-java-temurin-21.outputs.cache-hit == 'false'
285+
run: sbt +update
286+
246287
- name: Submit Dependencies
247288
uses: scalacenter/sbt-dependency-submission@v2
248289
with:
@@ -316,6 +357,19 @@ jobs:
316357
if: matrix.java == 'temurin@17' && steps.setup-java-temurin-17.outputs.cache-hit == 'false'
317358
run: sbt +update
318359

360+
- name: Setup Java (temurin@21)
361+
id: setup-java-temurin-21
362+
if: matrix.java == 'temurin@21'
363+
uses: actions/setup-java@v4
364+
with:
365+
distribution: temurin
366+
java-version: 21
367+
cache: sbt
368+
369+
- name: sbt update
370+
if: matrix.java == 'temurin@21' && steps.setup-java-temurin-21.outputs.cache-hit == 'false'
371+
run: sbt +update
372+
319373
- name: Generate site
320374
run: sbt docs/tlSite
321375

.mergify.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pull_request_rules:
1212
- body~=labels:.*early-semver-patch
1313
- status-success=Build and Test (ubuntu-latest, 2.13, temurin@11)
1414
- status-success=Build and Test (ubuntu-latest, 2.13, temurin@17)
15+
- status-success=Build and Test (ubuntu-latest, 2.13, temurin@21)
1516
- status-success=Build and Test (ubuntu-latest, 3, temurin@11)
1617
- status-success=Generate Site (ubuntu-latest, temurin@11)
1718
actions:

build.sbt

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import com.typesafe.tools.mima.core._
2+
import explicitdeps.ExplicitDepsPlugin.autoImport.moduleFilterRemoveValue
23

34
lazy val root = project
45
.in(file("."))
@@ -11,10 +12,6 @@ lazy val core = project
1112
name := "http4s-jdk-http-client",
1213
libraryDependencies ++= coreDeps,
1314
mimaBinaryIssueFilters ++= Seq(
14-
// package private, due to #641
15-
ProblemFilters.exclude[IncompatibleMethTypeProblem](
16-
"org.http4s.jdkhttpclient.JdkHttpClient.defaultHttpClient"
17-
)
1815
)
1916
)
2017

@@ -82,7 +79,7 @@ ThisBuild / developers := List(
8279
)
8380

8481
ThisBuild / tlJdkRelease := Some(11)
85-
ThisBuild / githubWorkflowJavaVersions := Seq("11", "17").map(JavaSpec.temurin(_))
82+
ThisBuild / githubWorkflowJavaVersions := Seq("11", "17", "21").map(JavaSpec.temurin(_))
8683
ThisBuild / tlCiReleaseBranches := Seq("main")
8784
ThisBuild / tlSitePublishBranch := Some("main")
8885

@@ -97,6 +94,7 @@ lazy val docsSettings =
9794
Versions
9895
.forCurrentVersion(Version("1.x", "1.x"))
9996
.withOlderVersions(
97+
Version("0.10.x", "0.10"),
10098
Version("0.9.x", "0.9"),
10199
Version("0.8.x", "0.8"),
102100
Version("0.7.x", "0.7"),

core/src/main/scala/org/http4s/jdkhttpclient/JdkHttpClient.scala

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,16 +69,23 @@ object JdkHttpClient {
6969
case Entity.Strict(bytes) =>
7070
Resource.pure[F, BodyPublisher](BodyPublishers.ofInputStream(() => bytes.toInputStream))
7171
case Entity.Streamed(body, _) =>
72+
def consumeFully = version match {
73+
case HttpClient.Version.HTTP_1_1 => req.isChunked
74+
case HttpClient.Version.HTTP_2 => req.contentLength.isEmpty
75+
}
7276
flow
7377
.toPublisher(body.chunks.map(_.toByteBuffer))
7478
.map { publisher =>
75-
if (req.isChunked)
79+
if (consumeFully)
7680
BodyPublishers.fromPublisher(publisher)
7781
else
7882
req.contentLength match {
7983
case Some(length) if length > 0L =>
8084
BodyPublishers.fromPublisher(publisher, length)
81-
case _ => BodyPublishers.noBody
85+
case _ =>
86+
// If we dont do this, we might block finalization
87+
publisher.subscribe(DrainingSubscriber)
88+
BodyPublishers.noBody
8289
}
8390
}
8491
}
@@ -252,9 +259,22 @@ object JdkHttpClient {
252259
* [[cats.effect.kernel.Async.executor executor]], sets the
253260
* [[org.http4s.client.defaults.ConnectTimeout default http4s connect timeout]], and disables
254261
* [[https://github.com/http4s/http4s-jdk-http-client/issues/200 TLS 1.3 on JDK 11]].
262+
*
263+
* On Java 21 and higher, it actively closes the underlying client, releasing its resources
264+
* early. On earlier Java versions, closing the underlying client is not possible, so the release
265+
* is a no-op. On these Java versions (and there only), you can safely use
266+
* [[cats.effect.Resource allocated]] to avoid dealing with resource management.
255267
*/
256-
def simple[F[_]](implicit F: Async[F]): F[Client[F]] =
257-
defaultHttpClient[F].map(apply(_))
268+
def simple[F[_]](implicit F: Async[F]): Resource[F, Client[F]] =
269+
defaultHttpClientResource[F].map(apply(_))
270+
271+
private[jdkhttpclient] def defaultHttpClientResource[F[_]](implicit
272+
F: Async[F]
273+
): Resource[F, HttpClient] =
274+
Resource.make[F, HttpClient](defaultHttpClient[F]) {
275+
case c: AutoCloseable => Sync[F].blocking(c.close())
276+
case _ => Applicative[F].unit
277+
}
258278

259279
private[jdkhttpclient] def defaultHttpClient[F[_]](implicit F: Async[F]): F[HttpClient] =
260280
F.executor.flatMap { exec =>
@@ -296,4 +316,12 @@ object JdkHttpClient {
296316
"via",
297317
"warning"
298318
).map(CIString(_))
319+
320+
private object DrainingSubscriber extends Flow.Subscriber[ByteBuffer] {
321+
override def onSubscribe(subscription: Flow.Subscription): Unit =
322+
subscription.request(Long.MaxValue)
323+
override def onNext(item: ByteBuffer): Unit = ()
324+
override def onError(throwable: Throwable): Unit = ()
325+
override def onComplete(): Unit = ()
326+
}
299327
}

core/src/main/scala/org/http4s/jdkhttpclient/JdkWSClient.scala

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@ object JdkWSClient {
131131
})
132132
} yield ()
133133
}
134+
// If the input side is still open (no close received from server), the JDK will not clean up the connection.
135+
// This also implies the client can't be shutdown on Java 21+ as it waits for all open connections
136+
// to be be closed. As we don't expect/handle anything coming on the input anymore
137+
// at this point, we can safely abort.
138+
_ <- F.delay(webSocket.abort())
134139
} yield ()
135140
}
136141
.map { case (webSocket, queue, closedDef, sendSem) =>
@@ -164,7 +169,12 @@ object JdkWSClient {
164169
* [[cats.effect.kernel.Async.executor executor]], sets the
165170
* [[org.http4s.client.defaults.ConnectTimeout default http4s connect timeout]], and disables
166171
* [[https://github.com/http4s/http4s-jdk-http-client/issues/200 TLS 1.3 on JDK 11]].
172+
*
173+
* * On Java 21 and higher, it actively closes the underlying client, releasing its resources
174+
* early. On earlier Java versions, closing the underlying client is not possible, so the release
175+
* is a no-op. On these Java versions (and there only), you can safely use
176+
* [[cats.effect.Resource allocated]] to avoid dealing with resource management.
167177
*/
168-
def simple[F[_]](implicit F: Async[F]): F[WSClient[F]] =
169-
JdkHttpClient.defaultHttpClient[F].map(apply(_))
178+
def simple[F[_]](implicit F: Async[F]): Resource[F, WSClient[F]] =
179+
JdkHttpClient.defaultHttpClientResource[F].map(apply(_))
170180
}

core/src/test/scala/org/http4s/jdkhttpclient/BodyLeakExample.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ object BodyLeakExample extends IOApp {
5555
.withPort(port"8080")
5656
.withHttpApp(app)
5757
.build
58-
.product(Resource.eval(JdkHttpClient.simple[IO]))
58+
.product(JdkHttpClient.simple[IO])
5959
.use { case (_, client) =>
6060
for {
6161
counter <- Ref.of[IO, Long](0L)

core/src/test/scala/org/http4s/jdkhttpclient/DeadlockWorkaround.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ class DeadlockWorkaround extends CatsEffectSuite {
2929
test("fail to connect via TLSv1.3 on Java 11") {
3030
if (Runtime.version().feature() > 11) IO.pure(true)
3131
else
32-
(JdkHttpClient.simple[IO], JdkWSClient.simple[IO]).flatMapN { (http, ws) =>
32+
(JdkHttpClient.simple[IO], JdkWSClient.simple[IO]).tupled.use { case (http, ws) =>
3333
def testSSLFailure(r: IO[Unit]) = r.intercept[SSLHandshakeException]
3434
testSSLFailure(http.expect[Unit](uri"https://tls13.1d.pw")) *>
3535
testSSLFailure(ws.connectHighLevel(WSRequest(uri"wss://tls13.1d.pw")).use(_ => IO.unit))

core/src/test/scala/org/http4s/jdkhttpclient/JdkHttpClientSpec.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import org.typelevel.ci._
2828
import scala.concurrent.duration._
2929

3030
class JdkHttpClientSpec extends ClientRouteTestBattery("JdkHttpClient") {
31-
def clientResource: Resource[IO, Client[IO]] = Resource.eval(JdkHttpClient.simple[IO])
31+
def clientResource: Resource[IO, Client[IO]] = JdkHttpClient.simple[IO]
3232

3333
// regression test for https://github.com/http4s/http4s-jdk-http-client/issues/395
3434
test("Don't error with empty body and explicit Content-Length: 0") {

core/src/test/scala/org/http4s/jdkhttpclient/JdkWSClientSpec.scala

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class JdkWSClientSpec extends CatsEffectSuite {
4040
implicit val loggerFactory: LoggerFactory[IO] = NoOpFactory[IO]
4141

4242
val webSocket: IOFixture[WSClient[IO]] =
43-
ResourceSuiteLocalFixture("webSocket", Resource.eval(JdkWSClient.simple[IO]))
43+
ResourceSuiteLocalFixture("webSocket", JdkWSClient.simple[IO])
4444
val echoServerUri: IOFixture[Uri] =
4545
ResourceSuiteLocalFixture(
4646
"echoServerUri",

docs/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import org.http4s.jdkhttpclient.JdkHttpClient
5050
// It comes for free with `cats.effect.IOApp`:
5151
import cats.effect.unsafe.implicits.global
5252

53-
val client: IO[Client[IO]] = JdkHttpClient.simple[IO]
53+
val client: Resource[IO, Client[IO]] = JdkHttpClient.simple[IO]
5454
```
5555

5656
#### Custom clients
@@ -91,7 +91,7 @@ def fetchStatus[F[_]](c: Client[F], uri: Uri): F[Status] =
9191
c.status(Request[F](Method.GET, uri = uri))
9292

9393
client
94-
.flatMap(c => fetchStatus(c, uri"https://http4s.org/"))
94+
.use(c => fetchStatus(c, uri"https://http4s.org/"))
9595
.unsafeRunSync()
9696
```
9797

@@ -103,7 +103,7 @@ create a new `HttpClient` instance on every invocation:
103103

104104
```scala mdoc
105105
def fetchStatusInefficiently[F[_]: Async](uri: Uri): F[Status] =
106-
JdkHttpClient.simple[F].flatMap(_.status(Request[F](Method.GET, uri = uri)))
106+
JdkHttpClient.simple[F].use(_.status(Request[F](Method.GET, uri = uri)))
107107
```
108108

109109
@:@

0 commit comments

Comments
 (0)