Repository: http4s/http4s (scala)

Issue:
Ember client hangs when parsing response body from HEAD request
When using `"org.http4s" %% "http4s-ember-client" % "0.23.25"`, a HEAD request never completes when parsing the response body.

I've created a reproduction case here: https://github.com/yanns/reproduce-head-issue

If I only check the HTTP status, then the function completes:
```
    val request = Request[IO](method = HEAD, uri = uri"http://localhost:8080/hello")
    client.run(request).use { http4sResp =>
      IO.println(http4sResp.status)
    }
```

If I parse the HTTP response, then the function hangs:
```
    val request = Request[IO](method = HEAD, uri = uri"http://localhost:8080/hello")
    client.run(request).use { http4sResp =>
      http4sResp
        .bodyText
        .compile
        .string
        .flatMap { decodedBody =>
          IO.println(http4sResp.status + " " + decodedBody)
        }
    }
```

I don't have this issue with blaze (`"org.http4s" %% "http4s-blaze-client" % "0.23.16"`)

The HTTP server is an akka-http-server.

Pull request description:
Adds a flag to ember-core Parser.Response.parse to discard the body

this is only enabled for HTTP/1.1 HEAD requests, but there may be other http methods which does not expect a body in the response.

Not sure if this is the best location for this code, but the reproducer now passes.

Patch:
diff --git a/ember-client/shared/src/main/scala/org/http4s/ember/client/internal/ClientHelpers.scala b/ember-client/shared/src/main/scala/org/http4s/ember/client/internal/ClientHelpers.scala
index 22969b0b14f..1f5d34e2aa9 100644
--- a/ember-client/shared/src/main/scala/org/http4s/ember/client/internal/ClientHelpers.scala
+++ b/ember-client/shared/src/main/scala/org/http4s/ember/client/internal/ClientHelpers.scala
@@ -167,7 +167,7 @@ private[client] object ClientHelpers {
           // bytes from the pre-emptive read in this connection, which probably has not completed yet, hence the timout
           connection.nextRead.get.flatMap(_.get).rethrow.timeout(idleTimeout),
         ).flatMapN { (head, firstRead) =>
-          Parser.Response.parser(maxResponseHeaderSize)(
+          Parser.Response.parser(maxResponseHeaderSize, discardBody = req.method == Method.HEAD)(
             firstRead.foldLeft(head)(Util.concatBytes(_, _)),
             timeoutMaybe(connection.keySocket.socket.read(chunkSize), idleTimeout),
           )
diff --git a/ember-core/shared/src/main/scala/org/http4s/ember/core/Parser.scala b/ember-core/shared/src/main/scala/org/http4s/ember/core/Parser.scala
index a66d4b681a6..0575a8dfae4 100644
--- a/ember-core/shared/src/main/scala/org/http4s/ember/core/Parser.scala
+++ b/ember-core/shared/src/main/scala/org/http4s/ember/core/Parser.scala
@@ -373,6 +373,11 @@ private[ember] object Parser {
     def parser[F[_]: Concurrent](maxHeaderSize: Int)(
         buffer: Array[Byte],
         read: Read[F],
+    ): F[(Response[F], Drain[F])] = parser[F](maxHeaderSize, discardBody = false)(buffer, read)
+
+    def parser[F[_]: Concurrent](maxHeaderSize: Int, discardBody: Boolean)(
+        buffer: Array[Byte],
+        read: Read[F],
     ): F[(Response[F], Drain[F])] = {
       // per https://httpwg.org/specs/rfc7230.html#rfc.section.3.3.3
       def expectNoBody(status: Status): Boolean =
@@ -406,7 +411,11 @@ private[ember] object Parser {
         )
 
         resp <-
-          if (headerP.chunked) {
+          if (discardBody) {
+            (baseResp -> none[Array[Byte]].pure[F]).pure[F]
+          } else if (expectNoBody(prelude.status)) {
+            (baseResp -> finalBuffer.some.pure[F]).pure[F]
+          } else if (headerP.chunked) {
             Ref.of[F, Option[Array[Byte]]](None).product(Deferred[F, Headers]).map {
               case (rest, trailers) =>
                 baseResp
@@ -417,8 +426,6 @@ private[ember] object Parser {
                   ) ->
                   rest.get
             }
-          } else if (expectNoBody(prelude.status)) {
-            (baseResp -> (Some(finalBuffer): Option[Array[Byte]]).pure[F]).pure[F]
           } else {
             headerP.contentLength
               .fold(Body.parseUnknownBody(finalBuffer, read))(


Test patch:
diff --git a/ember-core/shared/src/test/scala/org/http4s/ember/core/ParserSuite.scala b/ember-core/shared/src/test/scala/org/http4s/ember/core/ParserSuite.scala
index 8ca0001d6f2..6b60ba26786 100644
--- a/ember-core/shared/src/test/scala/org/http4s/ember/core/ParserSuite.scala
+++ b/ember-core/shared/src/test/scala/org/http4s/ember/core/ParserSuite.scala
@@ -364,6 +364,39 @@ class ParsingSuite extends Http4sSuite {
     }
   }
 
+  test(
+    "Parser.Response.parser return everything if the body is discarded"
+  ) {
+    val defaultMaxHeaderLength = 4096
+    val raw =
+      """HTTP/1.1 200 OK
+        |Content-Length: 5
+        |
+        |helloeverything after the body""".stripMargin
+
+    val byteStream = Stream
+      .emit(raw)
+      .map(Helpers.httpifyString)
+      .through(text.utf8.encode)
+
+    for {
+      take <- Helpers.taking[IO, Byte](byteStream)
+      result <- Parser.Response
+        .parser[IO](defaultMaxHeaderLength, discardBody = true)(Array.emptyByteArray, take)
+      body <- result._1.body.through(text.utf8.decode).compile.string
+      rest <- Stream
+        .eval(result._2)
+        .unNone
+        .flatMap(chunk => Stream.chunk(Chunk.byteVector(ByteVector(chunk))))
+        .through(text.utf8.decode)
+        .compile
+        .string
+    } yield {
+      assertEquals(body, "")
+      assertEquals(rest, "")
+    }
+  }
+
   test("Parser.Response.parser should Parse a response without Content-Length") {
     val defaultMaxHeaderLength = 4096
     val raw =