This is an automated email from the ASF dual-hosted git repository.

pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-grpc.git


The following commit(s) were added to refs/heads/main by this push:
     new faabc60a trailer frame support in GrpcProtocol appears broken (#822)
faabc60a is described below

commit faabc60a155d660a4ac748f2a1bf5c0ea9e3a8f9
Author: PJ Fanning <[email protected]>
AuthorDate: Sat Aug 8 22:04:24 2026 +0100

    trailer frame support in GrpcProtocol appears broken (#822)
    
    * trailer frame support in GrpcProtocol appears broken
    
    * Update GrpcProtocolWebSpec.scala
    
    * support LF only splits in trailer
    
    * Update GrpcProtocolWeb.scala
    
    * Update GrpcProtocolWebSpec.scala
    
    * test issue
    
    * remove return from decodeTrailer
---
 .../pekko/grpc/internal/AbstractGrpcProtocol.scala |   3 +-
 .../pekko/grpc/internal/GrpcProtocolWeb.scala      |  46 +++-
 .../pekko/grpc/internal/GrpcProtocolWebSpec.scala  | 287 +++++++++++++++++++++
 3 files changed, 330 insertions(+), 6 deletions(-)

diff --git 
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/AbstractGrpcProtocol.scala
 
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/AbstractGrpcProtocol.scala
index 8cd4653c..3067c894 100644
--- 
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/AbstractGrpcProtocol.scala
+++ 
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/AbstractGrpcProtocol.scala
@@ -134,6 +134,7 @@ object AbstractGrpcProtocol {
         val reader = new ByteReader(strictAdapter(bs))
         val frameType = reader.readByte()
         val length = reader.readIntBE()
+        if (length < 0) throw new IllegalStateException(s"Frame length must 
not be negative, was $length")
         val data = reader.take(length)
         if (reader.hasRemaining) throw new IllegalStateException("Unexpected 
data")
         if ((frameType & 0x80) == 0) codec.uncompress((frameType & 1) == 1, 
data)
@@ -153,8 +154,8 @@ object AbstractGrpcProtocol {
         object ReadFrameHeader extends Step {
           override def parse(reader: ByteReader): ParseResult[Frame] = {
             val frameType = reader.readByte()
-            // If we want to support > 2GB frames, this should be unsigned
             val length = reader.readIntBE()
+            if (length < 0) throw new IllegalStateException(s"Frame length 
must not be negative, was $length")
 
             if (length == 0) ParseResult(Some(deframe(frameType, 
ByteString.empty)), ReadFrameHeader)
             else ParseResult(None, ReadFrame(frameType, length), 
acceptUpstreamFinish = false)
diff --git 
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/GrpcProtocolWeb.scala 
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/GrpcProtocolWeb.scala
index 1995b711..1912ae77 100644
--- 
a/runtime/src/main/scala/org/apache/pekko/grpc/internal/GrpcProtocolWeb.scala
+++ 
b/runtime/src/main/scala/org/apache/pekko/grpc/internal/GrpcProtocolWeb.scala
@@ -18,6 +18,7 @@ import pekko.NotUsed
 import pekko.grpc.GrpcProtocol._
 import pekko.http.scaladsl.model._
 import pekko.http.scaladsl.model.HttpEntity.{ Chunk, ChunkStreamPart }
+import pekko.http.scaladsl.model.headers.RawHeader
 import pekko.stream.scaladsl.Flow
 import pekko.util.{ ByteString, ByteStringBuilder }
 import io.grpc.{ Status, StatusException }
@@ -64,10 +65,10 @@ abstract class GrpcProtocolWebBase(subType: String) extends 
AbstractGrpcProtocol
     }
 
   private final def decodeFrame(frameHeader: Int, data: ByteString): Frame = {
-    (frameHeader & 80) match {
-      case 0 => DataFrame(data)
-      case 1 => TrailerFrame(decodeTrailer(data))
-      case f => throw new 
StatusException(Status.INTERNAL.withDescription(s"Unknown frame type [$f]"))
+    (frameHeader & 0x80) match {
+      case 0    => DataFrame(data)
+      case 0x80 => TrailerFrame(decodeTrailer(data))
+      case f    => throw new 
StatusException(Status.INTERNAL.withDescription(s"Unknown frame type [$f]"))
     }
   }
 
@@ -80,7 +81,42 @@ abstract class GrpcProtocolWebBase(subType: String) extends 
AbstractGrpcProtocol
     builder.result()
   }
 
-  private final def decodeTrailer(data: ByteString): List[HttpHeader] = ???
+  private final def decodeTrailer(data: ByteString): List[HttpHeader] = {
+    val str = data.utf8String
+    val len = str.length
+    val headers = List.newBuilder[HttpHeader]
+    var i = 0
+    while (i < len) {
+      // skip leading whitespace and blank lines
+      while (i < len &&
+        (str.charAt(i) == ' ' || str.charAt(i) == '\t' || str.charAt(i) == 
'\r' || str.charAt(i) == '\n'))
+        i += 1
+      if (i < len) {
+        // scan for colon (key:value separator), stopping at LF for malformed 
lines
+        val keyStart = i
+        while (i < len && str.charAt(i) != ':' && str.charAt(i) != '\n') i += 1
+        if (i >= len || str.charAt(i) == '\n') {
+          // no colon found before end-of-line — skip malformed line
+          if (i < len) i += 1 // skip LF
+        } else {
+          val keyEnd = i
+          i += 1 // skip ':'
+          // scan for LF (line terminator)
+          val valueStart = i
+          while (i < len && str.charAt(i) != '\n') i += 1
+          var valueEnd = i
+          // strip trailing CR
+          if (valueEnd > valueStart && str.charAt(valueEnd - 1) == '\r') 
valueEnd -= 1
+          i += 1 // skip LF
+          // trim and emit
+          val key = str.substring(keyStart, keyEnd).trim
+          val value = str.substring(valueStart, valueEnd).trim
+          if (key.nonEmpty) headers += RawHeader(key, value)
+        }
+      }
+    }
+    headers.result()
+  }
 
 }
 
diff --git 
a/runtime/src/test/scala/org/apache/pekko/grpc/internal/GrpcProtocolWebSpec.scala
 
b/runtime/src/test/scala/org/apache/pekko/grpc/internal/GrpcProtocolWebSpec.scala
new file mode 100644
index 00000000..670c08c5
--- /dev/null
+++ 
b/runtime/src/test/scala/org/apache/pekko/grpc/internal/GrpcProtocolWebSpec.scala
@@ -0,0 +1,287 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.grpc.internal
+
+import org.apache.pekko
+import pekko.actor.ActorSystem
+import pekko.grpc.GrpcProtocol.{ DataFrame, Frame, TrailerFrame }
+import pekko.http.scaladsl.model.HttpHeader
+import pekko.http.scaladsl.model.headers.RawHeader
+import pekko.stream.scaladsl.Source
+import pekko.stream.testkit.scaladsl.TestSink
+import pekko.testkit.TestKit
+import pekko.util.ByteString
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.wordspec.AnyWordSpecLike
+
+class GrpcProtocolWebSpec extends TestKit(ActorSystem()) with AnyWordSpecLike 
with Matchers {
+
+  val reader = GrpcProtocolWeb.newReader(Identity)
+  val writer = GrpcProtocolWeb.newWriter(Identity)
+
+  /** Construct a raw trailer frame: 1 byte flags (0x80) + 4 bytes length + 
data */
+  private def trailerFrameBytes(data: ByteString): ByteString = {
+    val header = new Array[Byte](5)
+    header(0) = 0x80.toByte
+    header(1) = (data.length >>> 24).toByte
+    header(2) = (data.length >>> 16).toByte
+    header(3) = (data.length >>> 8).toByte
+    header(4) = data.length.toByte
+    ByteString.fromArrayUnsafe(header, 0, 5) ++ data
+  }
+
+  "GrpcProtocolWeb" should {
+
+    "encode and decode a data frame" in {
+      val data = ByteString(Array[Byte](1, 2, 3, 4))
+      val frame = DataFrame(data)
+      val chunk = writer.encodeFrame(frame)
+
+      Source
+        .single(chunk.data)
+        .via(reader.frameDecoder)
+        .runWith(TestSink[Frame]())
+        .request(1)
+        .expectNext(frame)
+        .expectComplete()
+    }
+
+    "encode and decode a trailer frame" in {
+      val trailers = List[HttpHeader](
+        RawHeader("grpc-status", "0"),
+        RawHeader("grpc-message", ""))
+      val frame = TrailerFrame(trailers)
+      val chunk = writer.encodeFrame(frame)
+
+      val probe = Source
+        .single(chunk.data)
+        .via(reader.frameDecoder)
+        .runWith(TestSink[Frame]())
+        .request(1)
+
+      probe.expectNext() match {
+        case TrailerFrame(decoded) =>
+          (decoded should have).length(2)
+          decoded.head shouldBe RawHeader("grpc-status", "0")
+          decoded(1) shouldBe RawHeader("grpc-message", "")
+        case other => fail(s"Expected TrailerFrame, got $other")
+      }
+      probe.expectComplete()
+    }
+
+    "distinguish data frames from trailer frames by type bit" in {
+      val data = ByteString(Array[Byte](1, 2, 3))
+      val dataFrame = DataFrame(data)
+      val trailers = List[HttpHeader](RawHeader("grpc-status", "0"))
+      val trailerFrame = TrailerFrame(trailers)
+
+      val encodedData = writer.encodeFrame(dataFrame)
+      val encodedTrailer = writer.encodeFrame(trailerFrame)
+
+      val probe = Source(List(encodedData.data, encodedTrailer.data))
+        .via(reader.frameDecoder)
+        .runWith(TestSink[Frame]())
+        .request(2)
+
+      probe.expectNext(dataFrame)
+      probe.expectNext() match {
+        case TrailerFrame(decoded) =>
+          (decoded should have).length(1)
+          decoded.head shouldBe RawHeader("grpc-status", "0")
+        case other => fail(s"Expected TrailerFrame, got $other")
+      }
+      probe.expectComplete()
+    }
+
+    "decode trailer with LF-only line endings" in {
+      val trailerData = ByteString("grpc-status:0\ngrpc-message:ok\n")
+      val rawFrame = trailerFrameBytes(trailerData)
+
+      val probe = Source
+        .single(rawFrame)
+        .via(reader.frameDecoder)
+        .runWith(TestSink[Frame]())
+        .request(1)
+
+      probe.expectNext() match {
+        case TrailerFrame(decoded) =>
+          (decoded should have).length(2)
+          decoded.head shouldBe RawHeader("grpc-status", "0")
+          decoded(1) shouldBe RawHeader("grpc-message", "ok")
+        case other => fail(s"Expected TrailerFrame, got $other")
+      }
+      probe.expectComplete()
+    }
+
+    "decode trailer with extra whitespace around key and value" in {
+      val trailerData = ByteString("  grpc-status  :  0  \r\ngrpc-message : ok 
\r\n")
+      val rawFrame = trailerFrameBytes(trailerData)
+
+      val probe = Source
+        .single(rawFrame)
+        .via(reader.frameDecoder)
+        .runWith(TestSink[Frame]())
+        .request(1)
+
+      probe.expectNext() match {
+        case TrailerFrame(decoded) =>
+          (decoded should have).length(2)
+          decoded.head shouldBe RawHeader("grpc-status", "0")
+          decoded(1) shouldBe RawHeader("grpc-message", "ok")
+        case other => fail(s"Expected TrailerFrame, got $other")
+      }
+      probe.expectComplete()
+    }
+
+    "decode trailer with empty value" in {
+      val trailerData = ByteString("grpc-status:0\r\ngrpc-message:\r\n")
+      val rawFrame = trailerFrameBytes(trailerData)
+
+      val probe = Source
+        .single(rawFrame)
+        .via(reader.frameDecoder)
+        .runWith(TestSink[Frame]())
+        .request(1)
+
+      probe.expectNext() match {
+        case TrailerFrame(decoded) =>
+          (decoded should have).length(2)
+          decoded.head shouldBe RawHeader("grpc-status", "0")
+          decoded(1) shouldBe RawHeader("grpc-message", "")
+        case other => fail(s"Expected TrailerFrame, got $other")
+      }
+      probe.expectComplete()
+    }
+
+    "decode trailer with multiple colons in value" in {
+      val trailerData = ByteString("grpc-status:0\r\ncustom-header: some: 
value\r\n")
+      val rawFrame = trailerFrameBytes(trailerData)
+
+      val probe = Source
+        .single(rawFrame)
+        .via(reader.frameDecoder)
+        .runWith(TestSink[Frame]())
+        .request(1)
+
+      probe.expectNext() match {
+        case TrailerFrame(decoded) =>
+          (decoded should have).length(2)
+          decoded.head shouldBe RawHeader("grpc-status", "0")
+          decoded(1) shouldBe RawHeader("custom-header", "some: value")
+        case other => fail(s"Expected TrailerFrame, got $other")
+      }
+      probe.expectComplete()
+    }
+
+    "decode trailer with blank lines between entries" in {
+      val trailerData = 
ByteString("grpc-status:0\r\n\r\n\r\ngrpc-message:ok\r\n")
+      val rawFrame = trailerFrameBytes(trailerData)
+
+      val probe = Source
+        .single(rawFrame)
+        .via(reader.frameDecoder)
+        .runWith(TestSink[Frame]())
+        .request(1)
+
+      probe.expectNext() match {
+        case TrailerFrame(decoded) =>
+          (decoded should have).length(2)
+          decoded.head shouldBe RawHeader("grpc-status", "0")
+          decoded(1) shouldBe RawHeader("grpc-message", "ok")
+        case other => fail(s"Expected TrailerFrame, got $other")
+      }
+      probe.expectComplete()
+    }
+
+    "skip malformed lines with no colon in trailer" in {
+      val trailerData = ByteString("grpc-status:0\r\nmalformed 
line\r\ngrpc-message:ok\r\n")
+      val rawFrame = trailerFrameBytes(trailerData)
+
+      val probe = Source
+        .single(rawFrame)
+        .via(reader.frameDecoder)
+        .runWith(TestSink[Frame]())
+        .request(1)
+
+      probe.expectNext() match {
+        case TrailerFrame(decoded) =>
+          (decoded should have).length(2)
+          decoded.head shouldBe RawHeader("grpc-status", "0")
+          decoded(1) shouldBe RawHeader("grpc-message", "ok")
+        case other => fail(s"Expected TrailerFrame, got $other")
+      }
+      probe.expectComplete()
+    }
+
+    "decode empty trailer frame" in {
+      val trailerData = ByteString.empty
+      val rawFrame = trailerFrameBytes(trailerData)
+
+      val probe = Source
+        .single(rawFrame)
+        .via(reader.frameDecoder)
+        .runWith(TestSink[Frame]())
+        .request(1)
+
+      probe.expectNext() match {
+        case TrailerFrame(decoded) =>
+          decoded shouldBe empty
+        case other => fail(s"Expected TrailerFrame, got $other")
+      }
+      probe.expectComplete()
+    }
+
+    "decode trailer with no trailing newline" in {
+      val trailerData = ByteString("grpc-status:0")
+      val rawFrame = trailerFrameBytes(trailerData)
+
+      val probe = Source
+        .single(rawFrame)
+        .via(reader.frameDecoder)
+        .runWith(TestSink[Frame]())
+        .request(1)
+
+      probe.expectNext() match {
+        case TrailerFrame(decoded) =>
+          (decoded should have).length(1)
+          decoded.head shouldBe RawHeader("grpc-status", "0")
+        case other => fail(s"Expected TrailerFrame, got $other")
+      }
+      probe.expectComplete()
+    }
+
+    "reject frame with negative length" in {
+      // Construct a raw frame with a negative length (high bit set in length 
field)
+      // Frame format: 1 byte flags + 4 bytes length (big-endian) + data
+      val header = new Array[Byte](5)
+      header(0) = 0x00 // data frame, no compression
+      header(1) = 0x80.toByte // length byte 0 (sets sign bit -> negative int)
+      header(2) = 0x00
+      header(3) = 0x00
+      header(4) = 0x00
+      val rawFrame = ByteString.fromArrayUnsafe(header, 0, 5)
+
+      Source
+        .single(rawFrame)
+        .via(reader.frameDecoder)
+        .runWith(TestSink[Frame]())
+        .request(1)
+        .expectError()
+    }
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to