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-http.git


The following commit(s) were added to refs/heads/main by this push:
     new 7f71b136c perf: avoid copying WebSocket deflate/inflate output (#1235)
7f71b136c is described below

commit 7f71b136c08d76393c97de6e386400e175e6d612
Author: PJ Fanning <[email protected]>
AuthorDate: Fri Sep 11 10:24:07 2026 +0100

    perf: avoid copying WebSocket deflate/inflate output (#1235)
    
    * perf: avoid copying WebSocket deflate/inflate output
    
    Motivation:
    PerMessageDeflate buffers the output of the Deflater/Inflater in a
    ByteArrayOutputStream and then calls ByteString.fromArrayUnsafe on
    toByteArray. ByteArrayOutputStream.toByteArray always copies the buffer,
    so every compressed or decompressed WebSocket frame pays a full copy of
    its payload.
    
    Modification:
    Add an internal ByteStringOutputStream that extends
    ByteArrayOutputStream and exposes toByteStringUnsafe, wrapping the
    internal buffer in a ByteString without copying when most of the buffer
    is used, and copying to a right-sized array otherwise so a large buffer
    is not retained by a small payload. Use it for the inflate and deflate
    paths in PerMessageDeflate, which allocate the stream per call and
    discard it immediately afterwards.
    
    Result:
    No copy of the payload per WebSocket frame when permessage-deflate is
    enabled, and no oversized buffer retained when a frame only fills a
    small part of it.
    
    Tests:
    - sbt "http-core/testOnly 
org.apache.pekko.http.impl.util.ByteStringOutputStreamSpec 
org.apache.pekko.http.impl.engine.ws.WebSocketServerSpec" - 51 tests succeeded
    - scalafmt --list --mode diff-ref=upstream/main - no files reported
    - sbt headerCreateAll - headers added for the new files
    
    References:
    None - the ByteStringOutputStream implementation is adapted from Apache
    Pekko gRPC (Apache License 2.0), 
https://github.com/apache/pekko-grpc/pull/862
    
    * chore: add missing MiMa filters for HttpMessageParser completion handling
    
    Motivation:
    #1246 replaced the object-level `CompletionIsMessageStartError` with a
    trait-level `completionIsMessageStartError` in the internal
    `HttpMessageParser`, but the excludes file it added ends with the
    introducing comment and no filter lines, so `validatePullRequest` fails
    MiMa on http-core for every PR rebased onto current main.
    
    Modification:
    Add the three `HttpMessageParser` filters under the existing comment in
    `illegal-request-context.excludes`.
    
    Result:
    `sbt "http-core/mimaReportBinaryIssues"` passes again.
---
 .../illegal-request-context.excludes               |  3 +
 .../http/impl/engine/ws/PerMessageDeflate.scala    | 10 ++--
 .../http/impl/util/ByteStringOutputStream.scala    | 51 ++++++++++++++++
 .../impl/util/ByteStringOutputStreamSpec.scala     | 69 ++++++++++++++++++++++
 4 files changed, 128 insertions(+), 5 deletions(-)

diff --git 
a/http-core/src/main/mima-filters/2.0.x.backwards.excludes/illegal-request-context.excludes
 
b/http-core/src/main/mima-filters/2.0.x.backwards.excludes/illegal-request-context.excludes
index a7049a8bf..84b2c9a1a 100644
--- 
a/http-core/src/main/mima-filters/2.0.x.backwards.excludes/illegal-request-context.excludes
+++ 
b/http-core/src/main/mima-filters/2.0.x.backwards.excludes/illegal-request-context.excludes
@@ -23,3 +23,6 @@ 
ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.http.impl.e
 
ProblemFilters.exclude[IncompatibleSignatureProblem]("org.apache.pekko.http.impl.engine.parsing.ParserOutput#MessageStartError.unapply")
 
 # internal API: replaced by an instance level completion handling that can 
report the same context
+ProblemFilters.exclude[DirectMissingMethodProblem]("org.apache.pekko.http.impl.engine.parsing.HttpMessageParser.CompletionIsMessageStartError")
+ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.http.impl.engine.parsing.HttpMessageParser.org$apache$pekko$http$impl$engine$parsing$HttpMessageParser$_setter_$completionIsMessageStartError_=")
+ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.http.impl.engine.parsing.HttpMessageParser.completionIsMessageStartError")
diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/PerMessageDeflate.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/PerMessageDeflate.scala
index 771da9027..ad5141fab 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/PerMessageDeflate.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/ws/PerMessageDeflate.scala
@@ -17,7 +17,6 @@
 
 package org.apache.pekko.http.impl.engine.ws
 
-import java.io.ByteArrayOutputStream
 import java.util.Random
 import java.util.zip.Deflater
 import java.util.zip.Inflater
@@ -27,6 +26,7 @@ import org.apache.pekko
 import pekko.NotUsed
 import pekko.annotation.InternalApi
 import pekko.http.impl.settings.WebSocketCompressionSettingsImpl
+import pekko.http.impl.util.ByteStringOutputStream
 import pekko.http.scaladsl.model.headers.WebSocketExtension
 import pekko.stream.scaladsl.BidiFlow
 import pekko.stream.scaladsl.Flow
@@ -246,7 +246,7 @@ private[http] object PerMessageDeflate {
       try {
         val input = if (appendTail) data ++ EmptyStoredBlock else data
         inflater.setInput(input.toArrayUnsafe())
-        val output = new ByteArrayOutputStream(1024)
+        val output = new ByteStringOutputStream(1024)
         var count = inflater.inflate(buffer)
         while (count > 0) {
           decompressedMessageBytes += count
@@ -255,7 +255,7 @@ private[http] object PerMessageDeflate {
           output.write(buffer, 0, count)
           count = inflater.inflate(buffer)
         }
-        ByteString.fromArrayUnsafe(output.toByteArray)
+        output.toByteStringUnsafe
       } catch {
         case ex: DataFormatException =>
           throw new ProtocolException(s"Invalid WebSocket compressed message: 
${ex.getMessage}")
@@ -345,13 +345,13 @@ private[http] object PerMessageDeflate {
 
     private def deflate(data: ByteString, removeTail: Boolean): ByteString = {
       deflater.setInput(data.toArrayUnsafe())
-      val output = new ByteArrayOutputStream(1024)
+      val output = new ByteStringOutputStream(1024)
       var count = deflater.deflate(buffer, 0, buffer.length, 
Deflater.SYNC_FLUSH)
       while (count > 0) {
         output.write(buffer, 0, count)
         count = deflater.deflate(buffer, 0, buffer.length, Deflater.SYNC_FLUSH)
       }
-      val bytes = ByteString.fromArrayUnsafe(output.toByteArray)
+      val bytes = output.toByteStringUnsafe
       if (removeTail && bytes.endsWith(EmptyStoredBlock)) 
bytes.dropRight(EmptyStoredBlock.length) else bytes
     }
 
diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/util/ByteStringOutputStream.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/util/ByteStringOutputStream.scala
new file mode 100644
index 000000000..acb034ffd
--- /dev/null
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/util/ByteStringOutputStream.scala
@@ -0,0 +1,51 @@
+/*
+ * 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.http.impl.util
+
+import java.io.ByteArrayOutputStream
+
+import org.apache.pekko
+import pekko.annotation.InternalApi
+import pekko.util.ByteString
+
+/**
+ * INTERNAL API
+ *
+ * An [[java.io.OutputStream]] that buffers into a byte array like 
[[java.io.ByteArrayOutputStream]] but
+ * that can hand the buffered data over as a [[pekko.util.ByteString]] without 
copying it, unlike
+ * `ByteArrayOutputStream.toByteArray` which always creates a copy.
+ *
+ * Derived from the `ByteStringOutputStream` in Apache Pekko gRPC
+ * (https://github.com/apache/pekko-grpc/pull/862).
+ */
+@InternalApi
+private[http] final class ByteStringOutputStream(capacity: Int) extends 
ByteArrayOutputStream(capacity) {
+
+  /**
+   * Wraps the bytes written so far in a `ByteString`. The buffer may be 
shared with the returned
+   * `ByteString`, so this stream must not be written to, reset or reused 
afterwards.
+   */
+  def toByteStringUnsafe: ByteString =
+    if (count < 1) ByteString.empty
+    else if (count > (buf.length >> 1))
+      // Most of the buffer is used — wrap it to avoid a copy
+      ByteString.fromArrayUnsafe(buf, 0, count)
+    else
+      // Small amount of data in a large buffer — copy to right-size so the 
rest can be GC'd
+      ByteString.fromArray(buf, 0, count)
+}
diff --git 
a/http-core/src/test/scala/org/apache/pekko/http/impl/util/ByteStringOutputStreamSpec.scala
 
b/http-core/src/test/scala/org/apache/pekko/http/impl/util/ByteStringOutputStreamSpec.scala
new file mode 100644
index 000000000..a9130162b
--- /dev/null
+++ 
b/http-core/src/test/scala/org/apache/pekko/http/impl/util/ByteStringOutputStreamSpec.scala
@@ -0,0 +1,69 @@
+/*
+ * 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.http.impl.util
+
+import org.apache.pekko.util.ByteString
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.wordspec.AnyWordSpec
+
+class ByteStringOutputStreamSpec extends AnyWordSpec with Matchers {
+
+  "ByteStringOutputStream" must {
+
+    "return an empty ByteString when nothing was written" in {
+      new ByteStringOutputStream(16).toByteStringUnsafe should 
===(ByteString.empty)
+    }
+
+    "return the bytes written when the buffer is exactly filled" in {
+      val out = new ByteStringOutputStream(4)
+      out.write(Array[Byte](1, 2, 3, 4))
+      out.toByteStringUnsafe should ===(ByteString(1, 2, 3, 4))
+    }
+
+    "return the bytes written when the buffer was grown" in {
+      val out = new ByteStringOutputStream(2)
+      val data = Array.tabulate[Byte](1000)(i => i.toByte)
+      out.write(data)
+      out.toByteStringUnsafe should ===(ByteString(data))
+    }
+
+    "return the bytes written when only a small part of the buffer is used" in 
{
+      val out = new ByteStringOutputStream(1024)
+      out.write(Array[Byte](1, 2, 3))
+      out.write(4)
+      out.toByteStringUnsafe should ===(ByteString(1, 2, 3, 4))
+    }
+
+    "not retain the buffer when only a small part of it is used" in {
+      val out = new ByteStringOutputStream(1024)
+      out.write(Array[Byte](1, 2, 3))
+      // the ByteString is a copy, so it is not affected by later writes to 
the stream
+      val result = out.toByteStringUnsafe
+      out.write(Array[Byte](9, 9, 9))
+      result should ===(ByteString(1, 2, 3))
+    }
+
+    "write single bytes and byte ranges" in {
+      val out = new ByteStringOutputStream(8)
+      out.write(1)
+      out.write(Array[Byte](0, 2, 3, 0), 1, 2)
+      out.write(Array[Byte](4, 5, 6, 7, 8))
+      out.toByteStringUnsafe should ===(ByteString(1, 2, 3, 4, 5, 6, 7, 8))
+    }
+  }
+}


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

Reply via email to